Comfortable testing

2023-07-10

Tests are great. They provide proof that your app works and allows you to refactor without worrying about breaking it.

Everybody wants a test-covered codebase, but not everybody wants to write them.
Not too different from what 8x Mr olympia winner Ronnie Coleman said about bodybuilding

Everybody wants to be a bodybuilder, but nobody wants to lift no heavy-ass weights.

Here's a few things i do to make writing tests more comfortable

Stupid code

One test for one thing

A feature can have multiple things happen at once, write one test for each thing.
It can be tempting to test multiple things in one test when the set up is the same
but that makes tests harder to read and error messages less specific.

Copy pasting

In the three tests example below we copy paste the Arrange & Act step three times.
To people who don't differentiate between test and regular code this looks like a crime but having this code repeated simplifies the code.

Copy pasting the code improves readability and shows the developer the entire app state without having to look at helper functions or seeders.

Consider a user registration example

    /** @test */
    public function a_user_is_created_in_the_database_when_registering()
    {
        // Arrange
        $userData = [
            'name' => 'JohnDoe',
            'email' => 'john@doe.com',
            'password' => 'password'
        ];
        // Act
        $this->post('/register', $userData);

        // Assert
        $this->assertDatabaseHas('users', [
            'name' => $userData['name'],
            'email' => $userData['email'],
        ]);
        $this->assertDatabaseCount('users', 1);
    }


    /** @test */
    public function a_confirmation_email_is_sent_when_a_user_registers()
    {
        Event::fake();

        // Arrange
        $userData = [
            'name' => 'JohnDoe',
            'email' => 'john@doe.com',
            'password' => 'password'
        ];
        // Act
        $this->post('/register', $userData);

        // Assert

        Event::assertDispatched(UserRegisterdEmailSent::class, 1);
    }


    /** @test */
    public function user_is_redirected_when_registering()
    {
        // Arrange
        $userData = [
            'name' => 'JohnDoe',
            'email' => 'john@doe.com',
            'password' => 'password'
        ];
        // Act 
        $this->post('/register', $userData)
            // Assert
            ->assertRedirect('home');
    }
    

Versus one test covering it all

    /** @test */
    public function a_user_can_register()
    {
        Event::fake();
        // Arrange
        $userData = [
            'name' => 'JohnDoe',
            'email' => 'john@doe.com',
            'password' => 'password'
        ];
        // Act
        $this->post('/register', $userData)
            ->assertRedirect('home');

        // Assert
        $this->assertDatabaseHas('users', [
            'name' => $userData['name'],
            'email' => $userData['email'],
        ]);
        $this->assertDatabaseCount('users', 1);
        Event::assertDispatched(UserRegisterdEmailSent::class, 1);
    }

Specific failures

Having more tests makes it more clear what is going wrong when a test fails.
Which one provides a better error?
A ❌ a_confirmation_email_is_sent_when_a_user_registers
B ❌ a_user_can_register

No seeders

Seeders reduces the code you have to write each test, but it makes them harder to read and update. They also make writing new tests harder because you have to remember what is being seeded.

Use factories to set up your tests instead, more on those later.

Smart techniques

AAA Pattern

Your tests should follow the pattern of Arrange, Act, Assert

Arrange

Set up the state that we will test on

// Arrange
$user = User::factory()->create();
$thread = Thread::factory()->create();
$postData = ['message' => 'solid thread'];
$this->signIn($user);

Act

Preform the action we want to test

// Act
$response = $this->post("/threads/{$thread->id}", $postData);

Assert

Confirm what we expected to happen actually happened

// Assert
$this->assertEquals($response->statusCode, 201);
$this->assertDatabaseCount('posts', 1);
$this->assertDatabaseHas(
	'posts',
	[
		'message' => $postData['message'],
		'user_id' => $user->id,
		'thread_id' => $thread->id
	]
);

Refresh the Database

Every test should start from an empty DB. After every test the DB should be truncated.
If i had to pick one advice for you to follow it's this one.

Use a faker package

Faker saves you from technically passing but logically failing tests.

This examples URL SHOULD start with /thread/{$thread->id}/...
but still works with /thread/{$post->id}/... because $post->id and $thread->id both = 1

If we used faker to generate random IDs here we would catch the logically incorrect URL.

/** @test */
public function a_specific_comment_of_a_thread_can_be_fetched()
{
	// Arrange
	$thread = Thread::factory()->create();
	$post = Post::factory()->in($thread)->create();

	// Act
	$res = $this->get("/thread/{$post->id}/post/{$post->id}");

	// Assert
	$this->assertEquals($res->statusCode, Response::HTTP_OK);
	$this->assertEquals($res->data->text, $post->text);
}

Use factories

Factories make your Arrange step easier to read and less prone to hidden typos.
This is achieved by writing less code & using convention instead of configuration

In the examples below the factory & non factory do the same thing.

Less code

    // With factory
    $user = User::factory()->create();

	// Without factory
	$user2 = User::create([
		'id' => fake()->numberBetween(1, 100),
		'is_admin' => false,
		'email' => fake()->unique()->safeEmail(),
		'password' => 'strong_password',
	]);

Convention over configuration

	// With factory
	$verifiedAdmin = User::factory()
		->admin()
		->hasVerifiedEmail()
		->create();

	// Without factory
	$verifiedAdmin2 = User::create([
		'id' => fake()->numberBetween(1, 100),
		'is_admin' => true,
		'verified_email' => true,
		'email' => fake()->unique()->safeEmail(),
		'password' => 'storng_password',
	]);

Focus on the most important areas

Don't stress to reach a percentage of test coverage, aim to be confident that your code works as intended. Prioritize the most important areas.

One way to make sure you keep your important code covered is using TDD, but more on that another time.