Testing

Baander uses PHPUnit 12 with three test suites. Tests run inside the Docker container.

Test Suites

Suite Directory Scope
Unit tests/Unit/ Pure domain logic, no container, no framework
Functional tests/Functional/ With Symfony kernel container, database, and services
Integration tests/Integration/ External service integration tests

Running Tests

All commands run inside the app container via make exec:

# Run all tests
make phpunit

# Run a single test file
make exec cmd="./vendor/bin/phpunit tests/Unit/Catalog/Domain/Model/AlbumTest.php"

# Run a specific suite
make exec cmd="./vendor/bin/phpunit --testsuite Unit"

# Run a single test method
make exec cmd="./vendor/bin/phpunit --filter testCreateAlbum"

# Run with Xdebug off (faster)
make exec cmd="XDEBUG_MODE=off ./vendor/bin/phpunit"

Coverage

make phpunit runs with HTML, Clover, and JUnit report generation:

Conventions

Example: Unit Test (Domain Logic)

Unit tests live in tests/Unit/ and test pure domain logic with no framework or container:

final class AlbumTest extends TestCase
{
    public function testCreateAlbum(): void
    {
        $album = Album::create(
            libraryId: Uuid::generate(),
            title: 'Abbey Road',
            type: 'album',
        );

        $this->assertNotNull($album->getId());
        $this->assertNotNull($album->getPublicId());
        $this->assertSame('Abbey Road', $album->getTitle());
    }

    public function testCreateAlbumWithEmptyTitleThrows(): void
    {
        $this->expectException(InvalidArgumentException::class);

        Album::create(
            libraryId: Uuid::generate(),
            title: '',
            type: 'album',
        );
    }
}

Example: Unit Test (Application Handler)

Application handler tests mock domain interfaces (repositories, ports) but test real orchestration logic:

final class StartPlaybackHandlerTest extends TestCase
{
    public function testStartsPlaybackAndDispatchesEvent(): void
    {
        $sessionPort = $this->createMock(PartySessionPortInterface::class);
        $eventDispatcher = $this->createMock(EventDispatcherInterface::class);

        $handler = new StartPlaybackHandler($sessionPort, $eventDispatcher);

        $session = /* ... create or reconstitute a PartySession ... */;

        $sessionPort->method('findByUuid')->willReturn($session);
        $sessionPort->expects($this->once())->method('startPlayback');
        $eventDispatcher->expects($this->once())->method('dispatch');

        $command = new StartPlaybackCommand(sessionId: $session->getId(), position: 0);
        ($handler)($command);
    }
}

Example: Functional Test (API Endpoint)

Functional tests use the Symfony kernel container and hit real endpoints:

final class PlaylistControllerTest extends WebTestCase
{
    public function testCreatePlaylist(): void
    {
        $client = static::createClient();
        $client->loginUser($this->createTestUser());

        $client->request('POST', '/api/playlists', [
            'json' => ['name' => 'Chill Vibes', 'isPublic' => false],
        ]);

        $this->assertResponseIsSuccessful();
        $this->assertJsonContains(['data' => ['name' => 'Chill Vibes']]);
    }
}

Static Analysis

PHPStan runs alongside tests:

# Run PHPStan
make phpstan

# Generate a baseline for existing errors
make phpstan-baseline

Frontend Testing

The web frontend uses Vitest:

cd ui/web
yarn test              # Run tests
yarn test:watch        # Watch mode
yarn test:coverage     # With coverage

See the Frontend Development page for more details.