Skip to main content

Testing Guide

This guide covers writing and running tests in Logidav, including test structure, mocking patterns, and CI expectations.

Running Tests

# Full test suite
SYMFONY_DEPRECATIONS_HELPER=weak_vendors vendor/bin/simple-phpunit -c phpunit.xml.dist

# Single test file
vendor/bin/simple-phpunit tests/AppBundle/Services/SaleServiceTest.php

# Single test method
vendor/bin/simple-phpunit --filter testCalculateTotalWithDiscount tests/AppBundle/Services/SaleServiceTest.php

# Via Makefile
make test
tip

The SYMFONY_DEPRECATIONS_HELPER=weak_vendors environment variable suppresses deprecation warnings from third-party vendor code. Always use it when running tests locally to reduce noise.

Test Structure

Tests mirror the src/ directory structure under tests/:

tests/
├── AppBundle/
│ ├── Command/
│ │ └── Sale/
│ │ └── ProcessSaleCommandTest.php
│ └── Services/
│ └── SaleServiceTest.php
├── CoreBundle/
│ ├── Api/
│ │ └── DpdApiTest.php
│ └── Services/
│ └── DpdServiceTest.php
└── MeduseBundle/
└── Services/
└── Queue/
└── OrderProcessorTest.php

Naming conventions:

  • Test files end with Test.php suffix
  • Test classes end with Test suffix
  • Test methods start with test prefix
  • One test class per source class

Base Classes

Choose the appropriate base class depending on what you need:

Base ClassUse When
PHPUnit\Framework\TestCasePure unit tests with no framework dependencies
Symfony\Bundle\FrameworkBundle\Test\KernelTestCaseTests that need the service container
Symfony\Bundle\FrameworkBundle\Test\WebTestCaseTests that need the HTTP client
<?php
// tests/AppBundle/Services/SaleServiceTest.php

namespace Tests\AppBundle\Services;

use AppBundle\Services\SaleService;
use PHPUnit\Framework\TestCase;

class SaleServiceTest extends TestCase
{
private $saleService;
private $em;
private $logger;

protected function setUp()
{
$this->em = $this->createMock(\Doctrine\ORM\EntityManagerInterface::class);
$this->logger = $this->createMock(\Psr\Log\LoggerInterface::class);

$this->saleService = new SaleService($this->em, $this->logger);
}

public function testCalculateTotalReturnsCorrectAmount()
{
$items = [
['price' => 10.00, 'quantity' => 2],
['price' => 25.50, 'quantity' => 1],
];

$result = $this->saleService->calculateTotal($items);

$this->assertEquals(45.50, $result);
}
}

What to Test

Business Invariants

Test status transitions, validation rules, and calculation logic -- the core rules that must never break:

public function testOrderCannotTransitionFromShippedToPending()
{
$this->expectException(\InvalidArgumentException::class);
$this->saleService->updateStatus($order, 'pending');
}

API Client Responses

Mock the HTTP layer and test that your API client correctly parses responses and handles errors:

public function testGetTrackingReturnsTrackingInfo()
{
$api = $this->getMockBuilder(DpdApi::class)
->disableOriginalConstructor()
->getMock();

$api->method('request')
->willReturn(json_encode(['tracking_number' => 'DPD123', 'status' => 'delivered']));

$result = $api->getTracking('DPD123');

$this->assertEquals('delivered', $result['status']);
}

Queue Processor Logic

Test processors with mock queue entries to verify correct handling of success and failure:

public function testProcessorHandlesFailedEntryGracefully()
{
$queue = $this->createMock(Queue::class);
$queue->method('getId')->willReturn(1);
$queue->method('getPayload')->willReturn('invalid-json');

$results = $this->processor->execute([$queue]);

$this->assertEquals('error', $results[1]['status']);
}

Command Execution

Use Symfony's CommandTester for testing console commands:

use Symfony\Component\Console\Tester\CommandTester;

public function testCommandOutputsResults()
{
$command = new SyncProductsCommand();
// Set up container mock on the command...

$tester = new CommandTester($command);
$tester->execute([]);

$this->assertContains('processed', $tester->getDisplay());
$this->assertEquals(0, $tester->getStatusCode());
}

Mocking Patterns

Service Isolation

Always mock external dependencies. Never let unit tests call real APIs or databases:

// Mock EntityManager
$em = $this->createMock(EntityManagerInterface::class);
$em->expects($this->once())
->method('persist')
->with($this->isInstanceOf(Order::class));
$em->expects($this->once())
->method('flush');

// Mock Repository
$repo = $this->createMock(OrderRepository::class);
$repo->method('find')->with(42)->willReturn($mockOrder);

$em->method('getRepository')
->with(Order::class)
->willReturn($repo);

Mocking API Clients

$api = $this->createMock(DpdApi::class);

// Simulate successful API response
$api->method('createShipment')
->willReturn(['shipment_id' => 'S123', 'label_url' => 'https://...']);

// Simulate API failure
$api->method('createShipment')
->willThrowException(new \RuntimeException('API timeout'));

Testing with LockableTrait

Commands using LockableTrait create file-based locks. Handle this in tests:

protected function tearDown()
{
// Clean up any leftover lock files
$lockFile = sys_get_temp_dir() . '/sf.' . md5('your:command:name') . '.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
warning

Lock files persist across test runs if a test crashes. If you see "Command is already running" during tests, check for stale lock files in the system temp directory.

CI Expectations

Tests run automatically on Jenkins as part of the CI pipeline:

CheckCI ModeRequirement
PHPUnit testsfull + fastAll tests must pass
PHPStanfull + fastMust pass at configured level
php-cs-fixerfull onlyCode must match PSR-2/PSR-12 style
  • main branch and PRs to main run in full mode with all checks.
  • develop branch and PRs to develop run in fast mode with tests and basic PHPStan.
  • PHPStan runs with a 2G memory limit configured in CI.
  • The SYMFONY_DEPRECATIONS_HELPER=weak_vendors flag is set in CI to match local behavior.

Before pushing, verify locally:

# Run tests
SYMFONY_DEPRECATIONS_HELPER=weak_vendors vendor/bin/simple-phpunit -c phpunit.xml.dist

# Run PHPStan
vendor/bin/phpstan analyse

# Check code style
vendor/bin/php-cs-fixer fix --dry-run --diff

Checklist for New Tests

  • Test file mirrors the source file path under tests/
  • Test class extends the appropriate base class
  • All external dependencies are mocked
  • Both success and failure paths are covered
  • Lock files are cleaned up in tearDown() if testing locked commands
  • Tests pass locally before pushing

See Also