Back to Course |
[NEW] Laravel Project: From Start to Finish

Automated Tests and Git Feature Branches

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/LinksCrudTest
php artisan make:test Links/LinksValidationTest
php 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: