[WIP] Unit tests

Verze:

23. 10. 2025

Zodpovědná osoba:

Dominik Šlechta

Poslední aktualizace:

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

Unit Testing in Nette Framework with Codeception

Unit tests are the foundation of your testing strategy. They focus on testing individual pieces of code in complete isolation - typically a single method or function. The goal is to verify that your business logic works correctly regardless of databases, external APIs, or other dependencies.

What Makes a Good Unit Test

A good unit test is fast, isolated, and predictable. It should run in milliseconds, not seconds. It shouldn't depend on external resources like databases, file systems, or network connections. Most importantly, it should always produce the same result - if you run the same test 100 times, it should pass 100 times (or fail 100 times if there's a bug).

Unit tests follow the AAA pattern: Arrange, Act, Assert. First, you arrange your test data and set up the object you're testing. Then you act by calling the method you want to test. Finally, you assert that the result matches your expectations.

Running Unit Tests

In our Docker-based environment, all tests run through Docker Compose. Use these composer scripts to execute unit tests:

# Run all unit tests
composer test:unit

These commands automatically handle the Docker environment setup and execute tests in the isolated container.

Creating Your First Unit Test

Let's say you have a service that calculates product prices with VAT. Here's a simple example:

namespace App\Services;

class PriceCalculator
{
    private float $vatRate = 0.21; // 21% VAT
    
    public function calculateWithVat(float $price): float
    {
        return $price * (1 + $this->vatRate);
    }
    
    public function calculateDiscount(float $price, float $discountPercent): float
    {
        if ($discountPercent < 0 || $discountPercent > 100) {
            throw new \InvalidArgumentException('Discount must be between 0 and 100');
        }
        
        return $price * (1 - $discountPercent / 100);
    }
}

Your unit test for this service would look like this:

namespace Tests\Unit\Services;

use App\Services\PriceCalculator;
use Codeception\Test\Unit;

class PriceCalculatorTest extends Unit
{
    private PriceCalculator $calculator;
    
    protected function _before()
    {
        $this->calculator = new PriceCalculator();
    }
    
    public function testCalculateWithVat()
    {
        // Arrange - already done in _before()
        
        // Act
        $result = $this->calculator->calculateWithVat(100);
        
        // Assert
        $this->assertEquals(121, $result);
    }
    
    public function testCalculateDiscount()
    {
        $result = $this->calculator->calculateDiscount(100, 10);
        $this->assertEquals(90, $result);
    }
    
    public function testCalculateDiscountWithInvalidPercentage()
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->calculator->calculateDiscount(100, 150);
    }
}

The _before() method runs before each test, creating a fresh instance of the calculator. This ensures tests don't affect each other.

Test File Organization

Unit tests are located in the tests/Unit/ directory. The structure mirrors your application's namespace structure:

tests/
├── Unit/
│   ├── Services/
│   │   ├── PriceCalculatorTest.php
│   │   └── OrderServiceTest.php
│   ├── Model/
│   │   └── UserTest.php
│   └── Utils/
│       └── StringHelperTest.php
├── Unit.suite.yml
└── _Bootstrap.php

If you have App\Services\PriceCalculator, your test should be Tests\Unit\Services\PriceCalculatorTest. This makes it easy to find tests and understand what they're testing.

Testing Services with Dependencies

When testing services that depend on other services, you have two options: use dummy implementations or leverage our DI module.

Option 1: Manual Dummy Objects

Create simple objects that implement the interface your service needs:

namespace Tests\Unit\Services;

use App\Services\OrderService;
use App\Model\Order;
use Codeception\Test\Unit;

class OrderServiceTest extends Unit
{
    private OrderService $service;
    
    protected function _before()
    {
        $this->service = new OrderService();
    }
    
    public function testCalculateTotal()
    {
        // Create dummy order with dummy items
        $order = new Order();
        $order->items = [
            (object)['price' => 100, 'quantity' => 2],
            (object)['price' => 50, 'quantity' => 3],
        ];
        $order->shippingCost = 100;
        
        $total = $this->service->calculateTotal($order);
        
        $this->assertEquals(450, $total); // (100*2) + (50*3) + 100
    }
}

Option 2: Using the DI Module

Our custom czechgroup-codeception plugin includes a DI module that simplifies working with Nette's dependency injection container in tests. This is particularly useful when testing services with complex dependencies.

Access services from the DI container in your tests:

namespace Tests\Unit\Services;

use App\Services\EmailService;
use Codeception\Test\Unit;

class EmailServiceTest extends Unit
{
    private EmailService $emailService;
    
    protected function _before()
    {
        // Get service from DI container
        $this->emailService = $this->getService(EmailService::class);
    }
    
    public function testFormatRecipientName()
    {
        $formatted = $this->emailService->formatRecipientName('John', 'Doe');
        $this->assertEquals('John Doe', $formatted);
    }
}

This approach is beneficial when your service requires properly configured dependencies from the DI container, but remember - unit tests should still avoid database calls and external services.

Testing Without Database

Unit tests should never touch the database. If your code depends on database data, you use dummy data instead. Let's say you have a service that processes orders:

namespace App\Services;

use App\Model\Order;

class OrderService
{
    public function calculateTotal(Order $order): float
    {
        $total = 0;
        
        foreach ($order->items as $item) {
            $total += $item->price * $item->quantity;
        }
        
        if ($order->shippingCost) {
            $total += $order->shippingCost;
        }
        
        return $total;
    }
    
    public function isEligibleForFreeShipping(Order $order): bool
    {
        return $this->calculateTotal($order) >= 1000;
    }
}

Instead of loading real orders from the database, you create dummy objects in your test:

public function testIsEligibleForFreeShipping()
{
    $order = new Order();
    $order->items = [
        (object)['price' => 600, 'quantity' => 2],
    ];
    $order->shippingCost = 0;
    
    $result = $this->service->isEligibleForFreeShipping($order);
    
    $this->assertTrue($result);
}

public function testIsNotEligibleForFreeShipping()
{
    $order = new Order();
    $order->items = [
        (object)['price' => 100, 'quantity' => 5],
    ];
    $order->shippingCost = 0;
    
    $result = $this->service->isEligibleForFreeShipping($order);
    
    $this->assertFalse($result);
}

Testing Edge Cases

Good unit tests don't just test the happy path - they test edge cases and error conditions. Think about what could go wrong:

public function testCalculateTotalWithEmptyOrder()
{
    $order = new Order();
    $order->items = [];
    $order->shippingCost = 0;
    
    $total = $this->service->calculateTotal($order);
    
    $this->assertEquals(0, $total);
}

public function testCalculateTotalWithNullShippingCost()
{
    $order = new Order();
    $order->items = [
        (object)['price' => 100, 'quantity' => 1],
    ];
    $order->shippingCost = null;
    
    $total = $this->service->calculateTotal($order);
    
    $this->assertEquals(100, $total);
}

Common Assertions

Codeception provides many assertion methods for different scenarios:

// Equality
$this->assertEquals(expected, actual);
$this->assertNotEquals(expected, actual);

// Identity (strict comparison)
$this->assertSame(expected, actual);
$this->assertNotSame(expected, actual);

// Boolean
$this->assertTrue(condition);
$this->assertFalse(condition);

// Null
$this->assertNull(value);
$this->assertNotNull(value);

// Arrays
$this->assertContains(needle, haystack);
$this->assertCount(expectedCount, array);
$this->assertEmpty(array);

// Strings
$this->assertStringContainsString(needle, haystack);
$this->assertStringStartsWith(prefix, string);

// Exceptions
$this->expectException(ExceptionClass::class);
$this->expectExceptionMessage('error message');

Testing Private Methods

Sometimes developers wonder if they should test private methods. The answer is usually no - you should test the public interface of your class. Private methods are implementation details that may change. If a private method contains complex logic that needs testing, it might be a sign that the logic should be extracted into its own class with a public method.

However, if you really need to test a private method, you can use reflection:

public function testPrivateMethod()
{
    $reflection = new \ReflectionClass($this->calculator);
    $method = $reflection->getMethod('privateMethodName');
    $method->setAccessible(true);
    
    $result = $method->invoke($this->calculator, $argument);
    
    $this->assertEquals(expected, $result);
}

Bootstrap Configuration

The tests/_Bootstrap.php file initializes the testing environment. It's executed once before any tests run. Here you can set up autoloading, define test constants, or perform any global test setup:

<?php

// Autoload composer dependencies
require __DIR__ . '/../vendor/autoload.php';

// Define test constants
define('TEMP_DIR', __DIR__ . '/_temp');

// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', '1');

What to Test in Unit Tests

Focus on business logic - calculations, validations, transformations, and algorithms. Don't test framework code or simple getters and setters. Test conditions and branches in your code. If you have an if statement, write tests for both branches.

Test what could break. If a method has complex logic or has caused bugs before, it definitely needs tests. If it's a critical part of your application (like payment processing or user authentication), write comprehensive tests.

What Not to Test

Don't test external dependencies in unit tests. Database queries, HTTP requests, file operations - these belong in functional or acceptance tests. Don't test third-party libraries - assume they work correctly. Don't test code that's just passing data through without transformation.

Test Execution and Coverage

Run your unit tests frequently during development:

# Run all unit tests
composer test:unit

# Run specific test file
composer test:unit Services/PriceCalculatorTest

# Run with verbose output
composer test:unit -- -[v|vv|vvv]

#Run coverage (also it is possible to choose from different formats. Below are most common of them)
composer test:unit -- --coverage[-html|-xml|-text]

You can also generate coverage reports manually using Codeception's built-in coverage functionality if needed for specific analysis.

Best Practices

  • Keep tests simple - Each test should verify one specific behavior
  • Use descriptive names - Test names should clearly describe what they test
  • Follow AAA pattern - Arrange, Act, Assert structure improves readability
  • Test edge cases - Empty arrays, null values, boundary conditions
  • Keep tests fast - Unit tests should run in milliseconds
  • Avoid test interdependence - Each test should run independently
  • Use the _before() method - Reset state before each test

Summary

Unit tests are your first line of defense against bugs. They're fast to write, fast to run, and give you immediate feedback. When combined with functional and acceptance tests, they create a comprehensive safety net that lets you refactor and add features with confidence.

In our Docker-based environment with the czechgroup-codeception plugin, you have powerful tools at your disposal - from simple dummy objects to full DI container integration. Choose the approach that best fits your testing needs while keeping tests fast and isolated.

Next, explore functional testing to learn how to test your application's behavior with database interactions and HTTP requests.