6

Everything You Can Test In Your Laravel Application

 1 year ago
source link: https://christoph-rumpel.com/2023/3/everything-you-can-test-in-your-laravel-application
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

All test examples focus on testing concepts and can be applied to all testing frameworks. My examples are written with PEST.

Looking for something specific? Pick it:

Note: All examples can also be found on the dedicated repository.

Testing Page Response Status

Testing page response is one of the simplest tests to write; still, it is extremely useful.

It makes sure a page responds with the correct HTTP status code, primarily a 200 response.

it('gives back a successful response for home page', function () {
    $this->get('/')->assertOk();
});

It's a straightforward test, but knowing your home page does not throw an error is crucial. So, if you want to write your first test, this is the one to start with.

Reference: Learn more about testing page responses on the official docs.

Testing Page Response Text

This test is similar to the first page response test. We also test the response, but this time we are interested in the content of the response.

it('lists products', function () {
    // Arrange
    $firstProduct = Product::factory()->create();
    $secondProduct = Product::factory()->create();

    // Act & Assert
    $this->get('/')
        ->assertOk()
        ->assertSeeTextInOrder([
            $firstProduct->title,
            $secondProduct->title,
        ]);
});

Here we are ensuring we see our product titles on the home page. This is useful if you load the products from the database and ensure they are shown.

Here you also can be more specific, like when you only want to show released products.

it('lists released products', function () {
    // Arrange
    $releasedProduct = Product::factory()
        ->released()
        ->create();
        
    $draftProduct = Product::factory()
        ->create();

    // Act & Assert
    $this->get('/')
        ->assertOk()
        ->assertSeeText($releasedProduct->title)
        ->assertDontSeeText($draftProduct->title);
});

It also demos how to test something that is not shown, which can be helpful too. This test wouldn't be that helpful if we only have static text on the home page.

Reference: Learn more about testing page responses on the official docs.

Testing Page Response View

Next to testing the response status and content, you can also test the view that is returned.

it('returns correct view', function() {
    // Act & Assert
    $this->get('/')
        ->assertOk()
        ->assertViewIs('home');
});

You can take this even further and test the data that is passed to the view.

it('returns correct view', function() {
    // Act & Assert
    $this->get('/')
        ->assertOk()
        ->assertViewIs('home')
        ->assertViewHas('products');
});
Reference: Learn more about testing page responses on the official docs.

Testing Page Response JSON

Often you want to return JSON data from your API. This is where you can use Laravel's JSON helpers, like the assertJson method.

it('returns all products as JSON', function () {
    // Arrange
    $product = Product::factory()->create();
    $anotherProduct = Product::factory()->create();

    // Act & Assert
    $this->post('api/products')
        ->assertOk()
        ->assertJson([
            [
                'title' => $product->title,
                'description' => $product->description,
            ],
            [
                'title' => $anotherProduct->title,
                'description' => $anotherProduct->description,
            ],
        ]);
});
Reference: Learn more about testing page responses on the official docs.

Testing Against The Database

Since we store data in the database, we want to make sure that data is stored correctly. This is where Laravel can help you with some handy assertion helpers.

it('stores a product', function () {
    // Act
    $this->actingAs(User::factory()->create())
        ->post('product', [
        'title' => 'Product name',
        'description' => 'Product description',
    ])->assertSuccessful();

    // Assert
    $this->assertDatabaseCount(Product::class, 1);
    $this->assertDatabaseHas(Product::class, [
        'title' => 'Product name',
        'description' => 'Product description',
    ]);
});

The example ensures that a product is created and stored in the database for our post route.

Reference: Find all database assertions on the official documentation.

Testing Validation

Validation is a crucial part of many applications. You want to make sure that only valid data can be submitted. By default, Laravel sends validation errors back to the user, which we can check with the assertInvalid method.

it('requires the title', function () {
    // Act
    $this->actingAs(User::factory()->create())
        ->post('product', [
            'description' => 'Product description',
        ])->assertInvalid(['title' => 'required']);
});

it('requires the description', function () {
    // Act
    $this->actingAs(User::factory()->create())
        ->post('product', [
            'title' => 'Product name',
        ])->assertInvalid(['description' => 'required']);
});

When dealing with many validation rules, using datasets can be pretty helpful. This can clean up your tests a lot.

it('requires title and description tested with a dataset', function($data, $error) {
    // Act
    $this->actingAs(User::factory()->create())
        ->post('product', $data)->assertInvalid($error);
})->with([
    'title required' => [['description' => 'text'], ['title' => 'required']],
    'description required' => [['title' => 'Title'], ['description' => 'required']],
]);
Reference: Learn more about validation assertions on the official docs.

Testing Models / Relationships

First, I like to test every relationship of a model. To be precise, we do not want to test the functionality of the relationship; this is what Laravel already does. We want to make sure that relationships are defined.

it('has products', function () {
    // Arrange
    $user = User::factory()
        ->has(Product::factory())
        ->create();

    // Act
    $products = $user->products;

    // Assert
    expect($products)
        ->toBeInstanceOf(Collection::class)
        ->first()->toBeInstanceOf(Product::class);
});

Developers have different opinions about what logic should be handled in a model, and that's ok. But if you add it, make sure also to test it.

it('only returns released courses for query scope', function () {
    // Arrange
    Course::factory()->released()->create();
    Course::factory()->create();

    // Act & Assert
    expect(Course::released()->get())
        ->toHaveCount(1)
        ->first()->id->toEqual(1);
});

Another example would be a model accessor like:

protected function firstName(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),
        );
    }

And the test would look like this:

it('capitalizes the first character of the first name', function () {
    // Arrange
    $user = User::factory()->create(['first_name' => 'christoph'])

    // Act & Assert
    expect($user->first_name)
        ->toBe('Christoph');
});

Testing Sending Mails

Laravel provides a lot of testing helpers, especially when using Facades.

class PublishPodcastController extends Controller
{
    public function __invoke(Podcast $podcast)
    {
        // publish podcast

        Mail::to($podcast->author)->send(new PodcastPublishedMail());
    }
}

At the end of this controller, we are sending an email. In our test, we can hit the controller through an endpoint and make sure this email would have been sent.

it('sends email to podcast author', function() {
    // Arrange
    Mail::fake();
    $podcast = Podcast::factory()->create();

    // Act
    $this->post(route('publish-podcast', $podcast));

   // Assert
    Mail::assertSent(PodcastPublishedMail::class);
});

Always run the Mail::fake() method at the beginning of your tests when testing emails. This makes sure no actual email is being sent to a user.

Most helper methods like assertSent also accept a callback as the second argument. In our case, it receives the mailable object. It contains all the email data, like the email to which it needs to be sent.

This allows you to make even more assertions, like about the `to-address´ of the email.

Mail::assertSent(PodcastPublishedMail::class, function(PodcastPublishedMail $mail) use ($podcast) {
    return $mail->hasTo($podcast->author->email);
});
Reference: Learn more about testing mailables on the official docs.

Testing Mail Content

It also makes sense to test the content of an email. This is especially useful when you have a lot of emails in your application. You want to make sure that the content is correct.

it('contains the product title', function () {
    // Arrange
    $product = Product::factory()->make();

   // Act
    $mail = new PaymentSuccessfulMail($product);

    // Assert
    expect($mail)
        ->assertHasSubject('Your payment was successful')
        ->assertSeeInHtml($product->title);
});
Reference: Learn about all the other assertions for testing mail content on the official docs.

Testing Jobs & Queues

I like to test jobs and queues separately, starting from the outside. This means I test a job is being pushed to a queue.

it('dispatches an import products job', function () {
    // Arrange
    Queue::fake();

    // Act
    $this->post('import');

    // Assert
    Queue::assertPushed(ImportProductsJob::class);
});

This ensures that my job will be pushed to the queue for a specific trigger, like hitting an endpoint. Again, the Queue::fake() takes care of not pushing a job. We do not want to run the job at this point.

But we still have to test the job, right? Of course. It contains the crucial logic of this feature:

it('imports products', function() {
   
    // Act
    (new ImportProductsJob)->handle();

    // Assert
    $this->assertDatabaseCount(Product::class, 50);
    
    // Make more assertions about the imported data
})

This new test concentrates on the job and what it should do. We trigger the job directly by calling the handle on it, which every job has.

Testing Notifications

Notifications are great for informing your users about important events. Make sure to test them too:

it('sends notification about new product', function () {
    // Arrange
    Notification::fake();
    $user = User::factory()->create();
    $product = Product::factory()->create();

    // Act
    $this->artisan(InformAboutNewProductNotification::class, [
        'productId' => $product->id,
        'userId' => $user->id,
    ]);

    // Assert
    Notification::assertSentTo(
        [$user], NewProductNotification::class
    );
});

In the example above, we test a notification sent to a user when a new product is created. We are using the artisan method to trigger the notification. This is a great way to test notifications triggered by a command.

Again, there is a fake method for the notification facade that makes sure no actual notification is being sent.

Reference: Learn more about testing notifications on the official docs.

Testing Actions

Actions are just simple classes that have one specific job. They are a great way to organize your code, and separate your logic from your controllers to keep them clean. But how do you test them?

Let's start again from the outside. First, we want to test that our action is called when hitting a specific endpoint.

it('calls add-product-to-user action', function () {
    // Assert
    $this->mock(AddProductToUserAction::class)
        ->shouldReceive('handle')
        ->atLeast()->once();

    // Arrange
    $product = Product::factory()->create();
    $user = User::factory()->create();

    // Act
    $this->post("purchase/$user->id/$product->i");
});

We can do this by mocking our action class and expecting that the handle method is called. But, again, we are here not interested in what our action does; we want to make sure it is called when we hit our purchase controller.

To make this work, we must ensure that the container resolves our action.

class PurchaseController extends Controller
{
    public function __invoke(User $user, Product $product): void
    {
        app(AddProductToUserAction::class->handle($user, $product);

        // Send purchase success email, etc.
    }
}

Then we can also test the action itself. Like a job, we call the handle method to trigger the action.

it('adds product to user', function () {
    // Arrange
    $product = Product::factory()->create();
    $user = User::factory()->create();

    // Act
    (new AddProductToUserAction())->handle($user, $product);

    // Assert
    expect($user->products)
        ->toHaveCount(1)
        ->first()->id->toEqual($product->id);
});

Testing Exceptions

Sometimes it is a good thing when an exception is thrown because we intentionally want to stop the execution of our code. We can test that too.

it('stops if at least one account not found', function () {
    // Act
    $this->artisan(MergeAccountsCommand::class, [
        'userId' => 1,
        'userToBeMergedId' => 2,
    ]);
})->throws(ModelNotFoundException::class);

We can chain the throws method to our test in PEST. This will make sure that the exception is thrown.

Testing Units (Unit Tests)

Unit tests are great for testing small pieces of code, like a single method. No other dependencies are involved. This makes them very fast and easy to write.

Our example is about a data object. It contains a method that creates a new instance from an a webhook payload.

class UserData
{
    public function __construct(
        public string $email,
        public string $name,
        public string $country,
    )
    {}

    public static function fromWebhookPayload(array $webhookCallData): UserData
    {
        return new self(
            $webhookCallData['client_email'],
            $webhookCallData['client_name'],
            $webhookCallData['client_country'],
        );
    }
}

In the corresponding test, we only test what this method returns.

it('creates UserData object from paddle webhook call', function () {
    // Arrange
    $payload = [
      'client_email' => '[email protected]',
      'client_name' => 'Christoph Rumpel',
      'client_country' => 'AT',
    ];

    // Act
    $userData = UserData::fromWebhookPayload($payload);

    // Assert
    expect($userData)
        ->email->toBe('[email protected]')
        ->name->toBe('Christoph Rumpel')
        ->country->toBe('AT');
});

Faking HTTP Calls

Sometimes you need to make HTTP calls in your application. This could be to fetch data from an external API or to send data to another service. You often want to fake these calls in your tests so you do not have to rely on an external service.

it('import product', function () {
    // Arrange
    Http::fake();

    // Act & Assert
    // ...
});

The fake method on the HTTP facade will ensure no real call is made and that the response is always a 200 status code.

But you can be more specific too. For example, we are testing an action that fetches data from an external API and saves it to the database.

it('imports product', function() {
    // Arrange
    Http::fake([
        'https://christoph-rumpel.com/import' => Http::response([
            'title' => 'My new product',
            'description' => 'This is a description',
        ]),
    ]);
    $user = User::factory()->create();

    // Act
    (new ImportProductAction)->handle($user);

    // Assert
    $this->assertDatabaseHas(Product::class, [
        'title' => 'My new product',
        'description' => 'This is a description',
    ]);
});

Testing HTTP Calls

Next to faking HTTP calls, you can also test if a specific call was made. This is useful when you want to ensure that your code makes the right calls.

it('make the right call', function () {
    // Arrange
    Http::fake();
    $user = User::factory()->create();

    // Act
    (new ImportProductAction)->handle($user);

    // Assert
    Http::assertSent(function ($request) {
        return $request->url() === 'https://christoph-rumpel.com/import'
            && $request['accessToken'] === '123456';
    });
});
Reference: Learn more about faking HTTP calls on the official docs.

Mocking Dependencies

When working with code with dependencies, it can be helpful to mock them. This will let you concentrate on the logic of your code and not on the dependencies. This also means mocking can be useful for any kind of tests.

We already did that when testing our action classes, but this works with any dependency. In the following example, we have a controller with two dependencies: a payment provider and a mailer.

class PaymentController extends Controller
{
    public function __invoke(PaymentProvider $paymentProvider, Mailer $mailer)
    {
        $paymentProvider->handle();

        $mailer->to(auth()->user())->send(new PaymentSuccessfulMail);
    }
}

In our test, we want to focus on testing that the correct email is being sent. That's why we can mock the payment provider and expect the handle method to be called. As a result, our test will not fail, even though the actual payment provider is never called.

it('sends payment successful mail', function () {
    // Arrange
    Mail::fake();

    // Expect
    $this->mock(PaymentProvider::class)
        ->shouldReceive('handle')
        ->once();

    // Act
    $this->post('payment');

    // Assert
    Mail::assertSent(PaymentSuccessfulMail::class);
});

Conclusion

Creating a list in your head of everything you like to test in your applications is essential. But, of course, when learning new coding concepts, you must also learn new testing techniques. So your list will grow over time.

If you think I am missing something important, please let me know on Twitter. I am always happy to learn new things too.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK