# Relationship Tests

# Introduction

This chapter provides examples of testing relationship routes: i.e. the related, show, update, attach and detach relationship actions.

The examples involve both to-one relations, and to-many relations.

# To-One

To demonstrate tests for a to-one relation, we will imagine that our posts resource has an image relation.

To test that we can read the related images resource, we will use the following test:

use App\Models\Image;
use App\Models\Post;

public function test(): void
{
    $post = Post::factory()->for(Image::factory())->create();
    $image = $post->image;

    $expected = [
        'type' => 'images',
        'id' => (string) $image->getRouteKey(),
        'attributes' => [
            'url' => $image->url,
        ],
    ];

    $response = $this
        ->jsonApi()
        ->expects('images')
        ->get("/api/v1/posts/{$post->getRouteKey()}/image");

    $response->assertFetchedOne($expected);
}

We are expecting the GET /api/v1/posts/{post}/image route to return the related images resource. We therefore ensure our $expected data includes at least one attribute or relationship. We use assertFetchedOne to ensure the response is a 200 OK response with our expected data in the data member of the JSON:API document.

TIP

If we wanted to test an empty relationship, we would use the assertFetchedNull assertion.

# Show To-One Testing

To test that we can read the related images resource identifier, we will use the following test:

use App\Models\Image;
use App\Models\Post;

public function test(): void
{
    $post = Post::factory()->for(Image::factory())->create();
    $image = $post->image;

    $response = $this
        ->jsonApi()
        ->expects('images')
        ->get("/api/v1/posts/{$post->getRouteKey()}/relationships/image");

    $response->assertFetchedToOne($image);
}

We are expecting the GET /api/v1/posts/{post}/relationships/image route to return the resource identifier of the related images resource. The assertFetchedToOne assertion does this: it will check that a resource identifier for the provided Image model in the /data member of the response.

TIP

If we wanted to test an empty relationship, we would use the assertFetchedNull assertion.

# Update To-One Testing

We can also test updating the relationship as follows:

use App\Models\Image;
use App\Models\Post;

public function test(): void
{
    $post = Post::factory()->create();
    $image = Image::factory()->create();

    $data = [
        'type' => 'images',
        'id' => (string) $image->getRouteKey(),
    ];

    $response = $this
        ->jsonApi()
        ->expects('images')
        ->withData($data)
        ->patch("/api/v1/posts/{$post->getRouteKey()}/relationships/image");

    $response->assertFetchedToOne($image);

    $this->assertDatabaseHas('posts', [
        'id' => $post->getKey(),
        'image_id' => $image->getKey(),
    ]);
}

In this request, we provide a resource identifier for the Image model as our request data. The expected outcome is that the Image is attached to the Post model. We use a database assertion to check this has happened.

# To-Many

To demonstrate tests for a to-many relation, we will imagine that our posts resource has a tags relation.

To test that we can read the related tags resources, we will use the following test:

use App\Models\Post;
use App\Models\Tag;

public function test(): void
{
    $post = Post::factory()->create();
    $tags = Tag::factory()->count(2)->create();
    $post->tags()->attach($tags);

    $expected = $tags->map(fn(Tag $tag) => [
        'type' => 'tags',
        'id' => (string) $tag->getRouteKey(),
        'attributes' => [
            'name' => $tag->name,
        ],
    ])->all();

    $response = $this
        ->jsonApi()
        ->expects('tags')
        ->get("/api/v1/posts/{$post->getRouteKey()}/tags");

    $response->assertFetchedMany($expected);
}

We are expecting the GET /api/v1/posts/{post}/tags route to return the related tags resources. We therefore ensure our $expected data includes at least one attribute or relationship. We use assertFetchedMany to ensure the response is a 200 OK response with our expected data in the data member of the JSON:API document.

TIP

If we wanted to test an empty relationship, we would use the assertFetchedNone assertion.

# Show To-Many Testing

To test that we can read the related tags resource identifiers, we will use the following test:

use App\Models\Post;
use App\Models\Tag;

public function test(): void
{
    $post = Post::factory()->create();
    $tags = Tag::factory()->count(2)->create();
    $post->tags()->attach($tags);

    $response = $this
        ->jsonApi()
        ->expects('tags')
        ->get("/api/v1/posts/{$post->getRouteKey()}/relationships/tags");

    $response->assertFetchedToMany($tags);
}

We are expecting the GET /api/v1/posts/{post}/relationships/tags route to return the resource identifiers of the related tags resources. The assertFetchedToMany assertion does this: it will check that resource identifiers for the provided Tag models are in the /data member of the response.

TIP

If we wanted to test an empty relationship, we would use the assertFetchedNone assertion.

# Update To-Many Testing

The follow example tests updating a to-many relation:

use App\Models\Post;
use App\Models\Tag;

public function test(): void
{
    $post = Post::factory()->create();
    $post->tags()->attach($existing = Tag::factory()->create());
    $tags = Tag::factory()->count(2)->create();

    $data = $tags->map(fn(Tag $tag) => [
        'type' => 'tags',
        'id' => (string) $tag->getRouteKey(),
    ])->all();

    $response = $this
        ->jsonApi()
        ->expects('tags')
        ->withData($data)
        ->patch("/api/v1/posts/{$post->getRouteKey()}/relationships/tags");

    $response->assertFetchedToMany($tags);

    /** The existing tag should have been detached. */
    $this->assertDatabaseMissing('taggables', [
        'tag_id' => $existing->getKey(),
        'taggable_id' => $post->getKey(),
        'taggable_type' => Post::class,
    ]);

    /** These tags should have been attached. */
    foreach ($tags as $tag) {
        $this->assertDatabaseHas('taggables', [
            'tag_id' => $tag->getKey(),
            'taggable_id' => $post->getKey(),
            'taggable_type' => Post::class,
        ]);
    }
}

In this request, we provide resource identifiers for the Tag models as our request data. The expected outcome is that the existing Tag model is detached, and the new Tag models are attached. This is because a PATCH request for a to-many relationship must replace the contents of the relationship. I.e. it is a sync operation.

Our database assertions check the database has been updated correctly.

# Attach To-Many Testing

The follow example tests attaching new records to a to-many relation:

use App\Models\Post;
use App\Models\Tag;

public function test(): void
{
    $post = Post::factory()->create();
    $post->tags()->attach($existing = Tag::factory()->create());
    $tags = Tag::factory()->count(2)->create();

    $data = $tags->map(fn(Tag $tag) => [
        'type' => 'tags',
        'id' => (string) $tag->getRouteKey(),
    ])->all();

    $response = $this
        ->jsonApi()
        ->expects('tags')
        ->withData($data)
        ->post("/api/v1/posts/{$post->getRouteKey()}/relationships/tags");

    $response->assertNoContent();

    /** The existing tag should still be attached. */
    $this->assertDatabaseHas('taggables', [
        'tag_id' => $existing->getKey(),
        'taggable_id' => $post->getKey(),
        'taggable_type' => Post::class,
    ]);

    /** These tags should have been attached. */
    foreach ($tags as $tag) {
        $this->assertDatabaseHas('taggables', [
            'tag_id' => $tag->getKey(),
            'taggable_id' => $post->getKey(),
            'taggable_type' => Post::class,
        ]);
    }
}

In this request, we provide resource identifiers for the new Tag models as our request data. The expected outcome of the POST method is that the new tags are attached, and the existing tag remains attached. Our database assertions check the database has been updated correctly.

# Detach To-Many Testing

The follow example tests detaching records to a to-many relation:

use App\Models\Post;
use App\Models\Tag;

public function test(): void
{
    $post = Post::factory()->create();
    $post->tags()->attach($keep = Tag::factory()->create());
    $post->tags()->attach($detach = Tag::factory()->count(2)->create());

    $data = $detach->map(fn(Tag $tag) => [
        'type' => 'tags',
        'id' => (string) $tag->getRouteKey(),
    ])->all();

    $response = $this
        ->jsonApi()
        ->expects('tags')
        ->withData($data)
        ->delete("/api/v1/posts/{$post->getRouteKey()}/relationships/tags");

    $response->assertNoContent();

    /** The existing tag should still be attached. */
    $this->assertDatabaseHas('taggables', [
        'tag_id' => $keep->getKey(),
        'taggable_id' => $post->getKey(),
        'taggable_type' => Post::class,
    ]);

    /** These tags should have been detached. */
    foreach ($detach as $tag) {
        $this->assertDatabaseMissing('taggables', [
            'tag_id' => $tag->getKey(),
            'taggable_id' => $post->getKey(),
            'taggable_type' => Post::class,
        ]);
    }
}

In this request, we provide resource identifiers for the Tag models we want to detach as our request data. The expected outcome of the DELETE method is that these tags are detached - and any other already attached tags are retained. Our database assertions check the database has been updated correctly.

Last Updated: 2/18/2023, 4:02:56 PM