We've finished the Links CRUD feature but haven't actually tested it.
A common question I get is how to write automated tests and WHEN to write them. After all the project is done? Before the code, with TDD? The truth is somewhere in the middle.
I prefer to write tests immediately after the feature is finished. At that moment, the code is still fresh in your head or IDE, so it's easier to write the test cases.
In my experience, TDD forces you to guess the functionality before you know how it would work. On the other hand, when writing tests for the full project afterward, it will be hard to remember the functionality of the features created a long time ago.
So, we'll be working on the tests in this lesson.
Introducing Feature Branches
In the example of this course, we separated the Links CRUD feature and its automated tests in separate lessons. I did it to demonstrate the different approaches to branching:
- Links CRUD code is pushed to the
dev
branch - Links CRUD tests will be pushed to a new feature branch (which later will be merged into
dev
)
From here, we will use feature branches for the following lessons.
git checkout -b feature/links-tests
Notice: In real life, you should push Code and Tests in one commit. We separated them for educational purposes.
Writing Tests: CRUD, Validation and Multi-Tenancy
Let's discuss the question of WHAT to write tests for.
For this Links CRUD functionality, we will write three kinds of tests.
First, for a typical CRUD, we should simulate the situations for each Controller method: index()
, create()
, edit()
, etc. This is the so-called "happy path".
But, on top of that, we need to test the scenarios when something goes wrong. These are often overlooked by developers who focus only on the "shiny scenario". However, the majority of bugs happen precisely when someone enters unexpected data. So, our goal is to come up with those "bad scenarios" and ensure that our application deals with them properly, showing the errors.
Finally, we require that every user see only their links. This is the most simple definition of multi-tenancy, so we need to test whether this condition works well.
To illustrate these three concepts separately, we decided to generate three different test files:
php artisan make:test Links/LinksCrudTestphp artisan make:test Links/LinksValidationTestphp artisan make:test Links/TenancyTest
It's your preference; you may prefer to have one longer LinksTest
.
Also, you choose whether to use Pest (Laravel default now) or PHPUnit. I will use Pest in this course.
IMPORTANT: Separate Database for Testing
Our tests will create fake data in the database to simulate the scenarios. So, we must ensure it works on a separate testing database to avoid accidentally deleting data in our main database.
Many junior developers forget this step, so I'm writing a separate section on it.
I prefer to have SQLite for tests locally (at least for the beginning), so all we need to do is uncomment two lines in the default phpunit.xml
file that comes with Laravel.
<env name="CACHE_STORE" value="array"/><!-- <env name="DB_CONNECTION" value="sqlite"/> --> <!-- <env name="DB_DATABASE" value=":memory:"/> --> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="MAIL_MAILER" value="array"/>
Testing the CRUD
This is the code for the "happy path" of Links CRUD.
tests/Feature/Links/LinksCrudTest.php:
use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use function Pest\Laravel\actingAs;use function Pest\Laravel\assertDatabaseHas;use function Pest\Laravel\assertDatabaseMissing;use function Pest\Laravel\delete;use function Pest\Laravel\get;use function Pest\Laravel\post;use function Pest\Laravel\put; uses(RefreshDatabase::class); it('can create link', function () { $user = User::factory()->create(); actingAs($user); // Make sure the form loads for our User get(route('links.create')) ->assertStatus(200) ->assertSeeText('Title') ->assertSeeText('URL') ->assertSeeText('Description') ->assertSeeText('Position') ->assertSeeText('Create Link'); // Assume form was submitted post(route('links.store'), [ 'title' => 'Test Link', 'url' => 'https://test.com', 'description' => 'Test Description', 'position' => 1, ]) ->assertRedirect(route('links.index')) // Ensure the message is flashed ->assertSessionHas('message', 'Link created successfully.'); // Ensure the link was created in the database for the User assertDatabaseHas('links', [ 'title' => 'Test Link', 'url' => 'https://test.com', 'description' => 'Test Description', 'position' => 1, 'user_id' => $user->id, ]);}); it('can update link', function () { $user = User::factory()->create(); $link = Link::factory()->create([ 'user_id' => $user->id, ]); actingAs($user); // Make sure the form loads for our User get(route('links.edit', $link)) ->assertStatus(200) ->assertSeeText('Title') ->assertSeeText('URL') ->assertSeeText('Description') ->assertSeeText('Position') ->assertSeeText('Save'); // Assume form was submitted put(route('links.update', $link), [ 'title' => 'Updated Link', 'url' => 'https://updated.com', 'description' => 'Updated Description', 'position' => 2, ]) ->assertRedirect(route('links.index')) // Ensure the message is flashed ->assertSessionHas('message', 'Link updated successfully.'); // Ensure the link was updated in the database for the User assertDatabaseHas('links', [ 'title' => 'Updated Link', 'url' => 'https://updated.com', 'description' => 'Updated Description', 'position' => 2, 'user_id' => $user->id, ]);}); it('can delete link', function () { $user = User::factory()->create(); $link = Link::factory()->create([ 'user_id' => $user->id, ]); actingAs($user); // Assume form was submitted delete(route('links.destroy', $link), [ '_method' => 'DELETE', ]) ->assertRedirect(route('links.index')) // Ensure the message is flashed ->assertSessionHas('message', 'Link deleted successfully.'); // Ensure the link was deleted from the database for the User assertDatabaseMissing('links', [ 'id' => $link->id, ]);}); it('can list links', function () { $user = User::factory()->create(); $links = Link::factory(3) ->create([ 'user_id' => $user->id, ]); actingAs($user); // Make sure the links are listed for our User get(route('links.index')) ->assertStatus(200) // Ensure the links are displayed in DESC order (reverse of creation) ->assertSeeTextInOrder($links->pluck('title')->reverse()->toArray());}); it('can create link with no position but still generate one', function () { $user = User::factory()->create(); actingAs($user); // Assume form was submitted post(route('links.store'), [ 'title' => 'Test Link', 'url' => 'https://test.com', 'description' => 'Test Description', ]) ->assertRedirect(route('links.index')) // Ensure the message is flashed ->assertSessionHas('message', 'Link created successfully.'); // Ensure the link was created in the database for the User assertDatabaseHas('links', [ 'title' => 'Test Link', 'url' => 'https://test.com', 'description' => 'Test Description', 'position' => 1, 'user_id' => $user->id, ]);});
For those methods to work, we also need to fill in the rules for the fake data in the Links Factory class:
database/factories/LinkFactory.php:
namespace Database\Factories; use App\Models\Link;use App\Models\User;use Illuminate\Database\Eloquent\Factories\Factory;use Illuminate\Support\Carbon; class LinkFactory extends Factory{ protected $model = Link::class; public function definition() { return [ 'url' => $this->faker->url(), 'title' => $this->faker->word(), 'description' => $this->faker->text(), 'position' => $this->faker->randomNumber(), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), // Disabled for now, since we don't have issues workflow yet // 'issue_id' => Issue::factory(), 'user_id' => User::factory(), ]; }}
Now, we can run this:
php artisan test --filter=LinksCrudTest
Testing the Validation Process
What if someone enters invalid data in the Create Link form? Would our application properly show user-facing error messages instead of just "500 Server Error"? This is exactly what we need to test.
It's your personal preference how many rules to test. The rule of thumb is to test the ones that are most likely to fail and the ones that would cause the most trouble if they do fail.
However, we decided to test (almost?) all combinations of fields/rules, so here's the long code of that Test file with a lot of methods:
tests/Feature/Links/LinkValidationTest.php
use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use function Pest\Laravel\actingAs;use function Pest\Laravel\post;use function Pest\Laravel\put; uses(RefreshDatabase::class); test('validates link url', function () { $user = User::factory()->create(); $link = Link::factory()->create(['user_id' => $user->id]); actingAs($user); // Check for URL validation post(route('links.store'), [ 'url' => 'invalid-url', 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field must be a valid URL.']); put(route('links.update', $link->id), [ 'url' => 'invalid-url', 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field must be a valid URL.']); // Check for URL required validation post(route('links.store'), [ 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field is required.']); put(route('links.update', $link->id), [ 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field is required.']); // Check for URL string validation post(route('links.store'), [ 'url' => 123, 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field must be a string.']); put(route('links.update', $link->id), [ 'url' => 123, 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['url' => 'The url field must be a string.']);}); test('validates link title', function () { $user = User::factory()->create(); $link = Link::factory()->create(['user_id' => $user->id]); actingAs($user); // Check for title validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => '', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['title' => 'The title field is required.']); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => '', 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['title' => 'The title field is required.']); // Check for title string validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => 123, 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['title' => 'The title field must be a string.']); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => 123, 'description' => 'Link Description', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['title' => 'The title field must be a string.']);}); test('validates link description', function () { $user = User::factory()->create(); $link = Link::factory()->create(['user_id' => $user->id]); actingAs($user); // Check for description string validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 123, 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['description' => 'The description field must be a string.']); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 123, 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasErrors(['description' => 'The description field must be a string.']); // Check for description nullable validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasNoErrors(); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'position' => 1, ]) ->assertStatus(302) ->assertSessionHasNoErrors();}); test('validates link position', function () { $user = User::factory()->create(); $link = Link::factory()->create(['user_id' => $user->id]); actingAs($user); // Check for position integer validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 'invalid', ]) ->assertStatus(302) ->assertSessionHasErrors(['position' => 'The position field must be an integer.']); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 'Link Description', 'position' => 'invalid', ]) ->assertStatus(302) ->assertSessionHasErrors(['position' => 'The position field must be an integer.']); // Check for position nullable validation post(route('links.store'), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 'Link Description', ]) ->assertStatus(302) ->assertSessionHasNoErrors(); put(route('links.update', $link->id), [ 'url' => 'https://example.com', 'title' => 'Link Title', 'description' => 'Link Description', ]) ->assertStatus(302) ->assertSessionHasNoErrors();});
Let's see if it works:
php artisan test --filter=LinksValidationTest
Testing the Multi-Tenancy
The final part is checking if users see only their records.
tests/Feature/Links/TenancyTest.php
use App\Models\Link;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use function Pest\Laravel\actingAs;use function Pest\Laravel\delete;use function Pest\Laravel\get;use function Pest\Laravel\put; uses(RefreshDatabase::class); test('user tenancy is applied to list', function () { $user = User::factory()->create(); $link = Link::factory()->create([ 'user_id' => $user->id, 'title' => 'My Link', ]); $secondUser = User::factory()->create(); $secondUserLinks = Link::factory(2)->create([ 'user_id' => $secondUser->id, 'title' => 'Second User Link', ]); actingAs($user); get(route('links.index')) // Assert the user's link is visible ->assertSee($link->title) // Assert the second user's links are not visible ->assertDontSee($secondUserLinks->first()->title) ->assertDontSee($secondUserLinks->last()->title);}); test('user tenancy prevents access to other user data', function () { $user = User::factory()->create(); $link = Link::factory()->create([ 'user_id' => $user->id, 'title' => 'My Link', ]); $secondUser = User::factory()->create(); $secondUserLink = Link::factory()->create([ 'user_id' => $secondUser->id, 'title' => 'Second User Link', ]); actingAs($user); // Can't edit or update other user's links get(route('links.edit', $secondUserLink->id)) ->assertStatus(404); put(route('links.update', $secondUserLink->id), [ 'title' => 'Updated Title', 'url' => 'https://updated.com', ]) ->assertStatus(404); // Can't delete other user's links delete(route('links.destroy', $secondUserLink->id)) ->assertStatus(404);});
Now, let's try to run them all together:
php artisan test
Okay, so we have written the code for our tests - yay! Let's push it to GitHub.
Push to a FEATURE Branch and PR to Dev
So, our code is now on a feature branch, which is exactly what we planned locally for now. Let's commit and push it to the remote.
The important part is that we can reference the issue we're working with by its ID in the commit message.
git add .git commit -m "Links CRUD Test Suite - resolves #2"git push origin feature/links-tests
Now, to "save" our feature and merge it with code by other team developers, we can open a Pull Request from our feature branch to the dev
branch that other developers are working with.
As soon as we see the "green light" from GitHub, we're good to merge the Pull Request:
Great. So now, when working on the next feature, we will create another new feature branch, and after finishing the feature, we will make a Pull Request to the dev
branch.
We repeat this process until we're ready to deploy the code to show the client from the dev
or main
branch; we will talk about that a few lessons from now.
Extra Git Resources for Reading
If you want to read more about feature branches and the overall Git workflow, here are a few resources:
In the course, you present two branches, but there is no mention of tagging. Could you explain how to use tags on these branches in the context of deployments? For example, when and why should we tag a commit, and how does it help during the deployment process
We haven't used tagging ourselves since it's not really common to have it on Forge (and Envoyer). They are working directly with branches on the assumption that:
Master is ALWAYS ready to be deployed Other branches are ready to be deployed with latest code at all times, but can break
And we used this workflow ourselves for the most part. We always made sure that as soon as stuff lands in master - we are 100% confident that it contains no unfinished tasks.
As for using tags - I've personally seen people do complete messes out of them, and some people who did it amazing. But there was just something off for me. We might investigate this deeper at a later date, but for now:
https://www.codingwithcalvin.net/git-tag-based-released-process-using-github-actions/ - Shows you how to set up tags with Actions. That way, you could trigger the deployment manually. https://docs.github.com/en/actions/use-cases-and-examples/deploying/deploying-with-github-actions - GitHub has some resources on that too (even for php, but only on azure https://docs.github.com/en/actions/use-cases-and-examples/deploying/deploying-php-to-azure-app-service
ps. I haven't found anything too specific for Laravel, sorry!
Thanks for this course! I have a question: after the merge of the feature branch, do you usually delete it? In this scenario: after merged the feature/links-tests into dev do you delete feature/links-tests itself? Thanks!
Yes! Clean up branches that were already merged to have less clutter in your system :) It's already merged, so it's not required anymore