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.