Testing Best Practices: The Ultimate Guide

·

15 min read

Testing Best Practices: The Ultimate Guide

Introduction

Hey there! Today, we’re going to explore the testing best practices. These little tips will make your life as a developer easier. I guarantee they will also help you understand testing better. I’ve spent roughly 10 years in the industry and have been a part of various projects with different testing approaches. Testing essentially serves as evidence that the business functions as expected. Why do we even need that evidence? Well, how can you be sure otherwise? Maybe you can be sure if your app is just a contact form. But, imagine the enterprise apps, let’s say your app is supposed to handle booking flight tickets. How can you manually test all those processes? There are too many parameters involved. You can’t ensure that you’re not going to break it. Let’s think about a travel booking system, from searching flights to storing customer data, generating tickets, processing payments, sending emails, SMS, and other notifications. What about notifying travel agencies about reserved spots? It’s a complex process that requires careful attention to detail. Apart from that, let’s imagine our favorite programming language released a new version. How would you ensure that updating to the latest version doesn’t break your business? Would you manually test every use case? It’s crucial to rely on automated tests. Let’s understand the practice!

Focus on Behavior

Developers often make the mistake of getting too caught up in the unimportant details. The purpose of enterprise software is to solve specific problems in an area. For instance, if we’re in the travel industry, our focus should be on solving problems related to searching for tickets, pricing, and booking. Our business makes money by helping its users efficiently book tickets. We want to ensure this process never breaks and we meet all business requirements. The implementation details change from time to time. We use different libraries. We discover another way to implement the same idea, perhaps a more performant one, and refactor some parts. However, the business requirements are still the same. Users still want to book a flight. They don’t care if we use a specific library or some specific SQL database. We could decide to store the same data in JSON files tomorrow. Users don’t care about these types of decisions, and neither should we. Those decisions shouldn’t even remotely affect our use cases.

Imagine we have a business requirement to send a confirmation email when users book a flight ticket. Let’s call it BookingConfirmationListener. I’ve seen a test like this before:

public function test_listener_is_called(): void 
{
    $booking = self::createBooking();

    $listenerMock = $this->createMock(BookingConfirmationListener::class);
    $listenerMock->expects($this->once())
        ->method('sendBookingConfirmation');
    self::setService(BookingConfirmationListener::class, $mock);

    $event = new BookingWasCreated($booking->id);

    self::dispatchEvent($event);
}

This test will pass when the sendBookingConfirmation method is called by the dispatched BookingWasCreated event. But, it doesn’t prove anything other than the fact the listener method was called. What if I wanted to rename this listener method? Do I need to update the test case as well? An extra cost of maintainability? What If I wanted to refactor it? Let’s say I changed its interval behavior and made a logical mistake. This test would still pass without catching that bug! It doesn’t add any confidence. We can’t ensure that our changes won’t break anything. We need to avoid these types of tests. We should focus on business requirements.

How can we switch our focus from implementation details to business requirements? Well, let’s rename this test case to be explicit, and let it explain the use case. We also need to add some behavioral characteristics to align with business requirements.

#[Test]
public function it_sends_booking_confirmation_email(): void
{
    // Arrange what's needed for this use case.
    $booking = self::createBooking();
    $event = new BookingWasCreated($booking->id);

    // Act
    self::dispatchEvent($event);

    // Assert against the business requirement of sending an email notification.
    // Did the "BookingWasCreated" event result in dispatching the expected notification?
    $expectedNotification = new BookingConfirmationEmailNotification($booking->id);
    self::assertTrue(self::hasNotificationBeenDispatched($expectedNotification));
}

Now, we can rename this listener or method. We can refactor it, even move that class from one place to another. These are implementation details. As long as our system meets the business requirement of producing a booking confirmation email, this test case won’t fail. It doesn’t matter how this requirement is implemented, as long as the behavior remains the same. We have the flexibility to make changes without worrying about breaking the system. If we break it, it will tell us. The test case gives us confidence when refactoring, knowing we can ensure the same behavior even after making changes.

Be Explicit

Test cases are a list of business requirements. They’re supposed to explain what that part of business should do. We want to make them as clear as possible because when it comes to maintaining that part, these cases will educate us and other developers about the business requirements while ensuring they work as expected. We should name our tests by business specifications, not arbitrary, implicit, technical, or made-up names.

public function test_is_successful(): void 
{
  // Test code
}

Is there any possibility of understanding what’s our business specification from looking at this test name? Not really. How could we improve it? Give it a clear name, and simply, describe the scenario and outcome.

#[Test]
public function it_stores_booking_confirmation(): void 
{
    // Arrange
    $booking = self::createBooking();
    $command = new StoreBookingConfirmationCommand($booking->id);

    // Act
    self::dispatchCommand($command);

    // Assert
    $expectedBookingConfirmation = BookingConfirmation::fromBooking($booking);
    $actualBookingConfirmation = self::getBookingConfirmationRepository()->get($booking->id);

    self::assertEquals($expectedBookingConfirmation, $actualBookingConfirmation);
}

Here is another clean example:

#[Test]
public function it_sends_create_account_notification(): void 
{
    // Arrange
    $user = self::createUser();

    // Act
    self::dispatchEvent(new UserCreated($user->id));

    // Assert
    $expectedNotification = new CreateAccountNotification($user->id);
    self::assertNotificationHasBeenDispatched($expectedNotification);
}

Even without context or explanation, these test cases are easy to understand. Of course, I have deliberately chosen simple examples to prove a point. But, even complex requirements should be approached the same way. Our focus should always be on the behavior. We should name our tests explicitly and keep them as simple as possible based on the behavior.

Don’t Test Configuration

Configurations are one of the implementation details. You don’t need to focus on implementation details. You don’t need to validate them in isolation. That is already validated when you test against the expected outcome. If your configuration is broken, your use case tests will fail anyway.

#[Test]
public function module_configuration_is_correct(): void
{
   // Arrange the test case & act
   $moduleConfig = // Get module config from a framework

   // Assert
   self::assertTrue($moduleConfig->has('some_parameter'));
   self::assertTrue($moduleConfig->has('validators'));
   self::assertCount(4, $moduleConfig->get('validators'));
}

If you encounter a configuration test similar to the one above and already have a behavior test, you can safely delete it. The behavior test should already validate this configuration. Of course, unit tests won’t catch this type of issue. I’d suggest keeping the bare minimum happy path in a more expensive test, such as integration testing. The integration test suite will help you detect this type of low-level configuration issue.

Don’t Test Simple Values

You don’t need to write tests for every little detail. Sometimes it’s redundant. We want to put our focus on what’s important. For instance, you don’t need to write a unit test for DataTransferObjects (DTO) or ValueObjects (VO) that will be used and validated by higher-level components. Tests should bring value and confidence to our development workflow. They should help reduce maintenance costs. These types of tests keep you slow when you want to change implementation details. Here is an example of a DataTransferObject (DTO) test:

#[Test]
public function successfully_constructs_create_user_dto(): void
{
    // Arrange the test case & act
    $dto = new CreateUserDto('username', 'password', 'email@example.com', notificationPreferences: [...]);

    // Assert
    self::assertSame('username', $dto->username);
    self::assertSame('password', $dto->password);
    self::assertSame('email@example.com', $dto->email);
    self::assertSame([...], $dto->notificationPreferences);
}

We could validate this DTO’s construction in a higher-level test case. We already know DTOs don’t have any behavior. They don’t have any meaning alone. We always use them within another context. Therefore, we don’t need to validate them in isolation. Let’s check out what that means with the following higher-level test:

#[Test]
public function it_creates_user(): void
{
    // Arrange
    $dto = new CreateUserDto('username', 'password', 'email@example.com', notificationPreferences: [...]);
    $userRepository = new InMemoryUserRepository();
    $passwordHasher = new StubPasswordHasher([
        'password' => 'hashedPassword',
    ]);
    $service = new CreateUserService($userRepository, $passwordHasher);

    // Act
    $service->createUser($dto);

    // Assert
    $actualUser = $userRepository->getByEmail('email@example.com');
    $expectedUser = User::withId($actualUser->id, 'username', 'email@example.com', notificationPreferences: [...]);
    $expectedUser->setHashedPassword('hashedPassword');

    self::assertEquals($expectedUser, $actualUser);
}

If the DataTransferObject (DTO) is in an invalid state, our higher-level test case will fail anyway. It will tell us this scenario needs some rework. I wouldn’t say this type of simple object test is completely useless. In some cases, they might become handy to pinpoint issues. However, that’s only true when they have a behavior, such as validation behavior in ValueObjects (VO). Even then, most of the time, we don’t need them. It’s rare to find a value in those tests. Let me explain why. Imagine a similar scenario. But this time, we have a ValueObject (VO) that carries some information. Let’s call it the Address object. Our use case would be adding a new address to an existing user. If we try to construct the Address ValueObject with invalid data, our higher-level test case should fail because the Address object won’t allow itself to be created in an invalid state. Even without having a separate test case for the ValueObject (VO), we can still ensure that a small unit is validated. Another good aspect of focusing on higher-level components is that we can catch invalid behaviors of the Address this way. Let’s say we have a bug in the validation of zip code. Our use case won’t produce the expected outcome. It will fail. We won’t have issues catching the bug that comes from the Address object in the higher-level test.

Remove Unnecessary Assertions

Multiple assertions only make sense when they bring value. Mostly, we exaggerate multiple assertions when we could assert against a simple outcome. When we think about our requirements, we think about some scenarios. If a user sends this form, we want to store their data in our database. If a user adds a new post, we want to notify their followers. If a user updates their profile information, we want to update a database record. All those scenarios have some outcome. Our classes and methods don’t necessarily return something, but they cause some effect we can assert against. Image a scenario where we create a user:

#[Test]
public function it_creates_user(): void
{
    // Arrange
    // ....

    // Act
    self::dispatchCommand(new CreateUser('User name', 'test@email.com', new DateTimeImmutable('01.01.1993')));

    // Assert
    self::assertSame('User name', $actualUser->name);
    self::assertSame('test@email.com', $actualUser->email);
    self::assertSame(new DateTimeImmutable('01.01.1993'), $actualUser->birthDate);
    self::assertTrue(self::isUserCreated($user->email));
}

We can simplify our test case by reducing multiple assertion calls to just one. These assertions are unnecessary because when we validate against the expected outcome, the expected user, we already validate for all those fields. If the expected user is not produced from our use case, the test case will naturally fail.

#[Test]
public function it_creates_user(): void
{
    // Arrange the test case & act
    // ....

    // Assert
    $actualUser = $userRepository->getByEmail('test@email.com');
    $expectedUser = new User('User name', 'test@email.com', new DateTimeImmutable('27.08.1993'));

    self::assertEquals($expectedUser, $actualUser);
}

Don’t Mock It

Mocking feels convenient when you start writing tests. It feels like you’re properly isolating your test cases while not putting much effort into creating or understanding the involved parts. That’s exactly why you shouldn’t use any mocking frameworks. You miss out on the “intent” part. Your test cases are supposed to explain your business requirements. Mocks clearly don’t express that intent. They’re tightly coupled to the implementation details.

Let’s understand this tight coupling with an actual case. Imagine the usual forgotten password scenario. We want to generate a link and send it to the user’s inbox. They can use this link to reset their password. We have a simple controller called ResetPasswordController with a simple method sendResetPasswordEmail. We have a service to generate a reset password link called ResetPasswordLinkGenerator. We also have the UserRepository to find information for the given user. Finally, the EmailService itself for actually sending the email.

Now, let’s try to create a unit test for the SendResetPasswordEmailController using mocks. The test case will result in the following:

#[Test]
public function it_sends_reset_password_email(): void
{
    $userMock = $this->createMock(User::class);

    $resetPasswordLinkGeneratorMock = $this->createMock(ResetPasswordLinkGenerator::class);
    $resetPasswordLink = 'https://example.com/reset-password';
    $resetPasswordLinkGeneratorMock->method('generateLink')
        ->willReturn($resetPasswordLink);

    $userRepositoryMock = $this->createMock(UserRepository::class);
    $userRepositoryMock->method('findByEmail')
        ->willReturn($userMock);

    $emailServiceMock = $this->createMock(EmailService::class);
    $emailServiceMock->expects($this->once())
        ->method('send')
        ->with(
            $userMock->getEmail(),
            'Password Reset',
            "Click the link to reset your password: $resetPasswordLink"
        );

    $controller = new SendResetPasswordEmailController(
        $userRepositoryMock,
        $resetPasswordLinkGeneratorMock,
        $emailServiceMock,
    );

    $controller->sendResetPasswordEmail('user@example.com');
}

This is a simple example where mocking is overused. Our codebases often have more complex cases. Those mocks hide the intent. The test case is hard to understand. It doesn’t explain the business requirements. Because those mocks are tightly coupled to the implementation details. Even if one of the mocks is misconfigured, the test case would still pass, which means it is not testing any behavior. It gives us false confidence. Additionally, if any implementation detail changes, it would be a burden to find the mocks that use those details. The tests should focus on behavior rather than implementation details. They should verify the expected outcomes and behaviors, rather than simply asserting that certain methods are called with specific arguments. Use mocks only for testing external dependencies, such as third-party sources.

We could easily improve the readability, maintainability, and intent by introducing test doubles. Let’s have a look together:

#[Test]
public function it_sends_reset_password_email(): void
{
    // Arrange
    $user = new User('user@example.com', 'Test User');
    $userRepository = new InMemoryUserRepository();
    $userRepository->save($user);    

    $resetPasswordLinkGenerator = new StubResetPasswordLinkGenerator('https://example.com/reset-password')

    $emailService = SpyEmailService();

    $controller = new SendResetPasswordEmailController(
        $userRepository,
        $resetPasswordLinkGenerator,
        $emailService,
    );

    // Act
    $controller->sendResetPasswordEmail('user@example.com');

    // Assert
    $expectedEmail = new ResetPasswordEmail('user@example.com');
    self::assertTrue($emailService->hasEmail($expectedEmail));
}

Remove If Else Assertions

Each requirement should have its test case. We want to ensure we’re not breaking anything while changing our software. We want to update our system confidently. The problem with if-else assertions comes from their nature of hiding multiple cases in one test. We want to isolate each test case and test a specific use case within its context and specification. Usually, the multiple conditionals tell us that there are different types of behavior hidden in one test case. Let’s have a look at that:

#[Test]
#[DataProvider('provideNotificationTypes')]
public function it_sends_account_created_notification(string $type): 
{
    // Arrange the test case & act
    // ....

    if ($type === 'push') {
        $expectedNotification = new PushAccountCreatedNotification(...);
        self::assertEquals($expectedNotification, self::getDispatchedNotification());
    } else ($type === 'SMS') {
        $expectedNotification = new SmsAccountCreatedNotification(...);
        self::assertEquals($expectedNotification, self::getDispatchedNotification());
  }
}

In this test case, we’re asserting against two different types of notifications. It’s hard to read. Also, it increases the fragility of the case. I would pretty much prefer the following:

#[Test]
public function it_sends_sms_account_created_notification(): 
{
    // Arrange the test case & act
    // ....

    $expectedNotification = new SmsAccountCreatedNotification(...);

    self::assertSame($expectedNotification, self::getLastDispatchedNotification());
}

#[Test]
public function it_sends_push_account_created_notification(): 
{
    // Arrange the test case & act
    // ....

    $expectedNotification = new PushAccountCreatedNotification(...);

    self::assertSame($expectedNotification, self::getLastDispatchedNotification());
}

Don’t Test Language Features

I’ll keep this section as simple as the title describes it already. You don’t need to test the language features. Let’s say you have the following method:

public function updateUser(): Response
{
  // ...
}

You don’t need to validate the return type:

#[Test]
public function it_updates_user(): void
{
  // ...
  $this->assertInstanceOf(Response::class, $updateUserRequest->send());
}

This assertion ultimately doesn’t validate anything. I can return a Response from this method while changing its interval behavior completely. This test won’t tell me I’m doing something wrong. Therefore, it doesn’t make sense to have a test like this. Additionally, the language would already warn me if this method doesn’t return the expected type. We don’t need to re-validate language features. Finally, when you validate the expected outcome, that type is already validated implicitly, without you asserting against it. Just test against the expected outcome:

#[Test]
public function it_updates_user(): void
{
  // ...
  // Build up the expected "Update User" response
  $expectedResponse = new Response(status:200, body: [...]);

  self::assertEquals($expectedResponse, $updateUserRequest->send());
}

Don’t Validate Prerequisites

Prerequisites are test arrangements that help us reach the expected outcome. Sometimes, we need a user to add a review. Sometimes, we need a review to send an email. Sometimes, we need a booking to generate a ticket. Imagine you need a database record before executing your task, and you have a fixture that handles the insertion. You don’t have to validate the integrity of that fixture helper. Because our goal is to focus on our use case; Our case should already tell us if there is an underlying problem. Imagine we have a service called UserUpdater. It’s a simple service that updates a database record.

public function updateUser(User $user): void
{
    // Some validation logic
    if (strlen($user->name) > 50) {
      // throw validation error
    }

    $this->updateDatabaseRecord($user);
}

We need a user in the database to validate this scenario. Sometimes we add assertCount or similar assertions; Just to validate the existence of the needed user, explicitly.

#[Test]
public function it_updates_user(): void
{
    $userUpdater = new UserUpdater(...);

    $user = self::createUser('Ozan');
    self::assertCount(1, self::getUserCountFromDatabase());
    self::assertSame('Ozan', self::getUserFromDatabase($user->id));

    $user->setName('Akman');
    $userUpdater->update($user);

    $expectedUser = User::withId($user->id, 'Akman');
    self::assertEquals($expectedUser, self::getUserFromDatabase($user->id));
}

The first problem with this approach is we are testing against different concepts. We’re testing against the user creation and the user update. Another issue is that it’s hiding a design flaw. If we want to ensure the existence of this user before the update, if that’s a requirement, then it shouldn’t happen in the test case. It should happen where other business requirements live: the use case itself. The reason we write these kinds of assertions is we’re not very confident with the use case itself. It’s usually pretty easy to shift that mindset from validation in a test case to validation in a use case:

public function updateUser(User $user): void
{
    if ($this->userRepository->get($user->id) === null) {
        throw UserNotFoundException();
    }

    // Other update user code
}

We want to ensure the update process works as expected. We don’t need to validate the user creation part. This will even make our test case simpler. With the change we did above, if there is an underlying problem with the user creation, our test case will fail. We don't need to assert against it, explicitly.

#[Test]
public function it_updates_user(): void
{
    $userUpdater = new UserUpdater(...);
    $user = self::createUser('Ozan');
    $user->setName('Akman');

    // This call will now throw an exception if there is no user in the database
    $userUpdater->update($user);

    $expectedUser = User::withId($user->id, 'Akman');
    self::assertEquals($user, self::getUserFromDatabase($user->id));
}

Create a Test Case for Each Requirement Change

Business requirements change from time to time. Imagine we have an e-commerce business. Our merchant profiles have a tab called Reviews. The initial requirement was simply to sort reviews by their creation date in descending order. We created a test case to validate this behavior. Great! Some years later, our product expanded into different markets. We started to operate in another country. The legal requirement in a new country is the updated reviews have to be on the top of the Reviews list. We implemented a simple Enum called ReviewOrder to tweak this setting. In this case, the default behavior remained the same. We still retrieve the reviews by their creation date in descending order. We can keep the initial test as it is, but we should provide another test case, that is specifically addressing this new behavior. If we don’t create this test case, tomorrow someone else will change this behavior and break the system, causing a legal issue, without even noticing it.

Conclusion

Congratulations if you made it here! We’ve learned important practices about automated testing! I hope they will help you create better test cases and add more confidence to your workflow. It’s important to remember these tips are there to help you. They’re not immutable laws. Sometimes it’s okay to bend these rules if there is a better fit for a specific case.

I hope to publish regularly about software and personal development topics. I’m trying to simplify complex technical concepts and provide a simpler language with a lot of examples. Feel free to share your thoughts in the comments!

If you enjoyed this article, let’s connect on https://twitter.com/akmandev and https://www.linkedin.com/in/ozanakman for more content like this!