[WIP] AAA pattern

Verze:

23. 11. 2025

Zodpovědná osoba:

Dominik Šlechta

Poslední aktualizace:

23. 11. 2025, dominikslechta.1@gmail.com

Writing Good Tests - Best Practices and Patterns

Writing tests is not just about achieving coverage numbers - it's about creating a reliable safety net that catches bugs early and documents how your code should behave. This article covers industry-standard practices and patterns that will help you write effective, maintainable tests.

The AAA Pattern - Arrange, Act, Assert

The AAA pattern is the most widely recognized structure for organizing tests. It divides each test into three distinct sections, making tests easy to read and understand.

Arrange - Set Up the Test

In this phase, you prepare everything needed for the test. This includes creating objects, setting up mock data, configuring dependencies, and establishing the initial state.

// Arrange
$user = new User();
$user->setEmail('[email protected]');
$user->setPassword('secret123');

Act - Execute the Action

This phase should ideally be a single line - the method call you're testing. This is the actual behavior you want to verify.

// Act
$result = $user->login();

Assert - Verify the Result

Finally, you verify that the action produced the expected result. Use specific assertions that clearly indicate what you expect.

// Assert
$this->assertTrue($result);
$this->assertEquals('John Doe', $user->getName());

Complete Example

public function testUserLogin()
{
    // Arrange
    $user = new User();
    $user->setEmail('[email protected]');
    $user->setPassword('secret123');
    
    // Act
    $result = $user->login();
    
    // Assert
    $this->assertTrue($result);
    $this->assertEquals('John Doe', $user->getName());
}

AAA Best Practices

  • Keep Act phase minimal - Ideally just one method call
  • Separate phases with blank lines - Improves readability for longer tests
  • Add comments for clarity - Use // Arrange, // Act, // Assert when tests are complex
  • One Act per test - If you need multiple actions, write multiple tests
  • Avoid logic in tests - No if statements, loops, or complex calculations

FIRST Principles - Quality Guidelines

The FIRST principles, introduced by Robert C. Martin, define what makes a test suite reliable and maintainable. Each letter represents a key quality that your tests should have.

F - Fast

Tests must run quickly. Slow tests discourage developers from running them frequently, which defeats their purpose. Unit tests should run in milliseconds.

// ❌ Slow - Makes actual HTTP request
public function testApiEndpoint()
{
    $response = file_get_contents('https://api.example.com/data');
    $this->assertNotEmpty($response);
}

// ✅ Fast - Uses mock
public function testApiEndpoint()
{
    $apiClient = $this->createMock(ApiClient::class);
    $apiClient->method('fetchData')->willReturn(['id' => 1]);
    
    $service = new DataService($apiClient);
    $result = $service->getData();
    
    $this->assertEquals(['id' => 1], $result);
}

I - Independent / Isolated

Each test must run independently without relying on other tests or shared state. Tests should not affect each other, and their order should not matter.

// ❌ Tests share state - bad!
private static $counter = 0;

public function testFirst()
{
    self::$counter++;
    $this->assertEquals(1, self::$counter);
}

public function testSecond()
{
    self::$counter++;
    $this->assertEquals(2, self::$counter); // Fails if tests run in different order!
}

// ✅ Each test is independent
public function testCalculation()
{
    $calculator = new Calculator();
    $result = $calculator->add(2, 3);
    $this->assertEquals(5, $result);
}

public function testAnotherCalculation()
{
    $calculator = new Calculator(); // Fresh instance
    $result = $calculator->multiply(4, 5);
    $this->assertEquals(20, $result);
}

Use Codeception's _before() method to reset state before each test:

protected function _before()
{
    $this->calculator = new Calculator();
    $this->database->cleanup();
}

R - Repeatable

Tests must produce the same result every time they run, regardless of the environment or when they're executed. Avoid dependencies on current time, random data, or external systems.

// ❌ Not repeatable - depends on current date
public function testIsAdult()
{
    $user = new User();
    $user->setBirthDate(new DateTime('2005-01-01'));
    $this->assertTrue($user->isAdult()); // Fails when year changes!
}

// ✅ Repeatable - uses fixed reference date
public function testIsAdult()
{
    $user = new User();
    $user->setBirthDate(new DateTime('2005-01-01'));
    $referenceDate = new DateTime('2023-01-01');
    $this->assertTrue($user->isAdult($referenceDate));
}

S - Self-Validating

Tests must have a clear pass/fail result with no manual verification needed. Use assertions to automatically validate outcomes.

// ❌ Not self-validating - requires manual check
public function testCalculation()
{
    $result = $this->calculator->add(2, 3);
    echo "Result: " . $result; // Developer must check if it's 5
}

// ✅ Self-validating - clear pass/fail
public function testCalculation()
{
    $result = $this->calculator->add(2, 3);
    $this->assertEquals(5, $result);
}

T - Timely / Thorough

Timely means writing tests at the right time - ideally before or immediately after writing production code (Test-Driven Development).

Thorough means testing not just the happy path, but also edge cases, error conditions, and boundary values.

// ✅ Thorough testing - covers multiple scenarios
public function testDivision_HappyPath()
{
    $result = $this->calculator->divide(10, 2);
    $this->assertEquals(5, $result);
}

public function testDivision_ByZero()
{
    $this->expectException(DivisionByZeroException::class);
    $this->calculator->divide(10, 0);
}

public function testDivision_NegativeNumbers()
{
    $result = $this->calculator->divide(-10, 2);
    $this->assertEquals(-5, $result);
}

public function testDivision_Decimals()
{
    $result = $this->calculator->divide(10, 3);
    $this->assertEquals(3.33, $result, '', 0.01);
}

Given-When-Then Pattern (BDD Style)

The Given-When-Then pattern is an alternative to AAA, commonly used in Behavior-Driven Development. It emphasizes describing behavior in business terms.

Pattern Structure

  • Given - The initial context and preconditions (equivalent to Arrange)
  • When - The action or event being tested (equivalent to Act)
  • Then - The expected outcome (equivalent to Assert)

Example in Codeception

public function testUserCanLoginWithValidCredentials(FunctionalTester $I)
{
    // Given - User has valid credentials
    $I->haveInDatabase('users', [
        'email' => '[email protected]',
        'password' => password_hash('secret123', PASSWORD_DEFAULT)
    ]);
    
    // When - User submits login form
    $I->amOnPage('/login');
    $I->fillField('email', '[email protected]');
    $I->fillField('password', 'secret123');
    $I->click('Login');
    
    // Then - User is logged in and redirected
    $I->seeInCurrentUrl('/dashboard');
    $I->see('Welcome back');
}

Using Gherkin Syntax

For acceptance tests, Codeception supports Gherkin syntax which makes tests even more readable:

Feature: User Authentication
  In order to access my account
  As a registered user
  I need to be able to log in

  Scenario: Successful login with valid credentials
    Given I am on the login page
    And I have a registered account with email "john@example.com"
    When I fill in "email" with "john@example.com"
    And I fill in "password" with "secret123"
    And I click "Login"
    Then I should be on the dashboard page
    And I should see "Welcome back"

Test Naming Conventions

Good test names are crucial for understanding what failed when a test breaks. The name should clearly describe what is being tested and what the expected outcome is.

Recommended Formats

// Format: test_MethodName_Scenario_ExpectedResult
public function test_Login_WithValidCredentials_ReturnsTrue()
public function test_Login_WithInvalidPassword_ReturnsFalse()
public function test_Login_WithNonExistentUser_ThrowsException()

// Format: test_Scenario_ExpectedResult
public function test_ValidCredentials_UserIsLoggedIn()
public function test_EmptyCart_TotalIsZero()
public function test_ExpiredCoupon_DiscountIsNotApplied()

// BDD style
public function shouldCalculateCorrectTotal_WhenCartHasMultipleItems()
public function shouldThrowException_WhenDividingByZero()

Naming Best Practices

  • Be descriptive - Anyone should understand what the test does without reading the code
  • Include the scenario - What conditions are being tested?
  • State the expected result - What should happen?
  • Use underscores for readability - Easier to read than camelCase for long names
  • Avoid technical jargon - Especially in acceptance tests, use business language

Test Structure Best Practices

One Assertion Per Concept

While you can have multiple assertions in a test, they should all verify the same logical outcome. If assertions test different concepts, split them into separate tests.

// ✅ Good - Multiple assertions for one concept
public function testSuccessfulLogin()
{
    $user = $this->authenticateUser('john@example.com', 'password');
    
    // All assertions verify "user is properly logged in"
    $this->assertTrue($user->isAuthenticated());
    $this->assertEquals('John', $user->getFirstName());
    $this->assertNotNull($user->getLastLoginDate());
}

// ❌ Bad - Testing multiple concepts
public function testUserManagement()
{
    $user = new User();
    $this->assertNull($user->getId()); // Tests creation
    
    $user->save();
    $this->assertNotNull($user->getId()); // Tests persistence
    
    $user->delete();
    $this->assertNull(User::find($user->getId())); // Tests deletion
    // Split into 3 separate tests!
}

Avoid Test Interdependence

Tests should never depend on the execution or results of other tests. Each test should set up its own data and clean up afterward.

// ❌ Bad - Tests depend on each other
public function testCreateUser()
{
    $user = User::create(['name' => 'John']);
    $this->assertNotNull($user->id);
}

public function testUpdateUser()
{
    $user = User::where('name', 'John')->first(); // Depends on previous test!
    $user->update(['name' => 'Jane']);
    $this->assertEquals('Jane', $user->name);
}

// ✅ Good - Each test is independent
public function testCreateUser()
{
    $user = User::create(['name' => 'John']);
    $this->assertNotNull($user->id);
}

public function testUpdateUser()
{
    // Create user in this test
    $user = User::create(['name' => 'John']);
    
    $user->update(['name' => 'Jane']);
    $this->assertEquals('Jane', $user->name);
}

Use Descriptive Assertion Messages

When an assertion fails, a good message helps you understand the problem immediately without debugging.

// ❌ Unclear when it fails
$this->assertTrue($result);

// ✅ Clear failure message
$this->assertTrue($result, 'User should be authenticated with valid credentials');

// ❌ Generic
$this->assertEquals(5, $total);

// ✅ Specific
$this->assertEquals(5, $total, 'Cart total should be 5 EUR for 2 items at 2.50 EUR each');

What to Test

Focus on Behavior, Not Implementation

Test what the code does, not how it does it. This makes tests more resistant to refactoring.

// ❌ Testing implementation details
public function testSorting()
{
    $sorter = new BubbleSorter();
    $result = $sorter->sort([3, 1, 2]);
    
    // Don't test that it used bubble sort algorithm
    $this->assertInstanceOf(BubbleSorter::class, $sorter);
}

// ✅ Testing behavior
public function testSorting()
{
    $sorter = new Sorter();
    $result = $sorter->sort([3, 1, 2]);
    
    // Test the outcome
    $this->assertEquals([1, 2, 3], $result);
}

Test Edge Cases and Boundaries

Don't just test the happy path. Consider what could go wrong and test those scenarios.

public function testStringLength_EmptyString()
{
    $result = strlen('');
    $this->assertEquals(0, $result);
}

public function testArrayAccess_InvalidIndex()
{
    $array = [1, 2, 3];
    $this->expectException(OutOfBoundsException::class);
    $value = $array[10];
}

public function testPrice_NegativeValue()
{
    $this->expectException(InvalidArgumentException::class);
    $product = new Product('Item', -10);
}

public function testPagination_LastPage()
{
    $paginator = new Paginator($items, $perPage = 10, $currentPage = 5);
    $this->assertFalse($paginator->hasMorePages());
}

public function testDateRange_BoundaryDates()
{
    $range = new DateRange('2024-01-01', '2024-01-31');
    $this->assertTrue($range->contains('2024-01-01')); // First day
    $this->assertTrue($range->contains('2024-01-31')); // Last day
    $this->assertFalse($range->contains('2024-02-01')); // Day after
}

Testing Pyramid Principle

The testing pyramid is a strategy for organizing your test suite. It recommends having many fast unit tests, fewer integration tests, and even fewer slow end-to-end tests.

        /\
       /  \      Few
      / E2E\     Slow, Expensive
     /______\    High-Level
    /        \
   /  Func.  \   More
  / Integ.    \  Medium Speed
 /____________\ Medium-Level
/              \
/   Unit Tests  \  Many
/________________\ Fast, Cheap
                   Low-Level

Why This Structure?

  • Unit tests are fast - Run in milliseconds, provide instant feedback
  • Unit tests are precise - When they fail, you know exactly what's broken
  • Integration tests catch interaction bugs - Test how components work together
  • E2E tests verify complete workflows - But they're slow and harder to maintain

Practical Application

In a typical project, you might have:

  • 70% Unit tests - Test business logic, calculations, validations
  • 20% Functional/Integration tests - Test controllers, database operations, API endpoints
  • 10% Acceptance/E2E tests - Test critical user workflows, payment processes, registration

Common Testing Anti-Patterns to Avoid

The Liar

A test that passes even when the functionality is broken.

// ❌ This test passes but doesn't test anything meaningful
public function testUserCreation()
{
    $user = new User();
    $this->assertNotNull($user); // User object always exists!
}

The Giant

A test that tries to test too much at once, making it hard to understand and maintain.

// ❌ Testing entire user lifecycle in one test
public function testUserCompleteLifecycle()
{
    // Registration
    $user = User::register('[email protected]', 'password');
    $this->assertNotNull($user->id);
    
    // Login
    $auth = Auth::login($user->email, 'password');
    $this->assertTrue($auth);
    
    // Update profile
    $user->update(['name' => 'Jane']);
    $this->assertEquals('Jane', $user->name);
    
    // Change password
    $user->changePassword('newpassword');
    $this->assertTrue(password_verify('newpassword', $user->password));
    
    // Delete account
    $user->delete();
    $this->assertNull(User::find($user->id));
    
    // Split into 5 separate tests!
}

The Mockery

Over-using mocks to the point where you're testing mocks instead of real behavior.

// ❌ Too many mocks - not testing real behavior
public function testProcessOrder()
{
    $cart = $this->createMock(Cart::class);
    $cart->method('getTotal')->willReturn(100);
    
    $payment = $this->createMock(PaymentGateway::class);
    $payment->method('charge')->willReturn(true);
    
    $inventory = $this->createMock(InventoryService::class);
    $inventory->method('reduce')->willReturn(true);
    
    $email = $this->createMock(EmailService::class);
    $email->method('send')->willReturn(true);
    
    // You're testing mocks, not real integration
}

The Inspector

Testing private methods or internal implementation details.

// ❌ Don't test private methods
public function testPrivateCalculation()
{
    $calculator = new Calculator();
    $reflection = new ReflectionClass($calculator);
    $method = $reflection->getMethod('privateCalculate');
    $method->setAccessible(true);
    
    $result = $method->invoke($calculator, 5, 10);
    $this->assertEquals(15, $result);
}

// ✅ Test through public interface
public function testPublicCalculation()
{
    $calculator = new Calculator();
    $result = $calculator->calculate(5, 10);
    $this->assertEquals(15, $result);
}

Applying These Principles in Codeception

Codeception makes it easy to follow these best practices across all test types:

Using _before() for Test Isolation

class UserTest extends \Codeception\Test\Unit
{
    protected $user;
    
    protected function _before()
    {
        // Reset state before each test
        $this->user = new User();
        // Clear database, reset mocks, etc.
    }
    
    public function testUserCreation()
    {
        // This user is fresh from _before()
        $this->user->setName('John');
        $this->assertEquals('John', $this->user->getName());
    }
}

Using Actors for Readable Tests

public function testUserCanPurchaseProduct(AcceptanceTester $I)
{
    // Given
    $I->amLoggedInAs('[email protected]');
    $I->haveProductInDatabase('Laptop', 999);
    
    // When
    $I->addToCart('Laptop');
    $I->proceedToCheckout();
    $I->fillPaymentDetails('4111111111111111');
    $I->confirmOrder();
    
    // Then
    $I->seeOrderConfirmation();
    $I->seeInDatabase('orders', [
        'user_email' => '[email protected]',
        'product' => 'Laptop'
    ]);
}

Using Examples for Data-Driven Tests

/**
 * @example [2, 3, 5]
 * @example [10, 5, 15]
 * @example [-5, 10, 5]
 * @example [0, 0, 0]
 */
public function testAddition($a, $b, $expected)
{
    $calculator = new Calculator();
    $result = $calculator->add($a, $b);
    $this->assertEquals($expected, $result);
}

Summary

Writing good tests is a skill that improves with practice. By following these patterns and principles, you'll create tests that:

  • Are easy to understand and maintain
  • Catch bugs early and reliably
  • Document how your code should behave
  • Give you confidence when refactoring
  • Run fast and provide quick feedback

Remember: the goal is not just to have tests, but to have good tests that actually help you deliver better software. Start with these principles, adapt them to your needs, and continuously improve your testing skills.

In the following articles, we'll explore how to apply these principles specifically to unit tests, functional tests, and acceptance tests in our Codeception setup.