Skip to content

🧪 Gold Standard Test Suite

Comprehensive testing strategies for maintainable, reliable code.

The Gold Standard module demonstrates best practices for testing XOOPS modules, with a focus on the testing pyramid and clean test architecture.


Testing Philosophy

flowchart TB
    subgraph Pyramid["Testing Pyramid"]
        E2E["🔝 E2E Tests (5%)"]
        INT["⬆️ Integration Tests (25%)"]
        UNIT["📦 Unit Tests (70%)"]
    end

    E2E --> |"Slow, Expensive<br/>Full stack"| INT
    INT --> |"Medium speed<br/>Component integration"| UNIT
    UNIT --> |"Fast, Cheap<br/>Isolated logic"| BASE[Foundation]

    style UNIT fill:#c8e6c9
    style INT fill:#fff3e0
    style E2E fill:#ffcdd2

Test Organization

tests/
├── Unit/
│   ├── Domain/
│   │   ├── Entity/
│   │   │   ├── ArticleTest.php
│   │   │   └── ArticleStatusTest.php
│   │   ├── ValueObject/
│   │   │   ├── ArticleIdTest.php
│   │   │   ├── ArticleTitleTest.php
│   │   │   └── ArticleContentTest.php
│   │   └── Service/
│   │       └── PublishingPolicyTest.php
│   ├── Application/
│   │   ├── Command/
│   │   │   ├── CreateArticleHandlerTest.php
│   │   │   └── PublishArticleHandlerTest.php
│   │   └── Query/
│   │       └── GetArticleHandlerTest.php
│   └── Infrastructure/
│       └── Persistence/
│           └── ArticleMapperTest.php
├── Integration/
│   ├── Repository/
│   │   └── XoopsArticleRepositoryTest.php
│   └── Service/
│       └── ArticleServiceTest.php
├── Functional/
│   ├── Api/
│   │   └── ArticleEndpointTest.php
│   └── Web/
│       └── ArticleControllerTest.php
└── Support/
    ├── Factory/
    │   └── ArticleFactory.php
    ├── Traits/
    │   ├── DatabaseSetup.php
    │   └── AuthenticationHelper.php
    └── TestCase/
        ├── UnitTestCase.php
        ├── IntegrationTestCase.php
        └── FunctionalTestCase.php

Unit Tests

Testing Entities

<?php

declare(strict_types=1);

namespace Tests\Unit\Domain\Entity;

use PHPUnit\Framework\TestCase;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Entity\ArticleStatus;
use Xoops\GoldStandard\Domain\ValueObject\{ArticleId, ArticleTitle, ArticleContent, AuthorId, CategoryId};
use Xoops\GoldStandard\Domain\Event\{ArticleCreated, ArticlePublished};
use Xoops\GoldStandard\Domain\Exception\InvalidStatusTransition;

final class ArticleTest extends TestCase
{
    public function testCreateArticle(): void
    {
        $article = $this->createArticle();

        $this->assertSame('Test Article', $article->getTitle()->value);
        $this->assertSame(ArticleStatus::Draft, $article->getStatus());
        $this->assertTrue($article->isDraft());
        $this->assertFalse($article->isPublished());
    }

    public function testCreateRecordsDomainEvent(): void
    {
        $article = $this->createArticle();

        $events = $article->pullDomainEvents();

        $this->assertCount(1, $events);
        $this->assertInstanceOf(ArticleCreated::class, $events[0]);
    }

    public function testPublishArticle(): void
    {
        $article = $this->createArticle();

        $article->publish();

        $this->assertSame(ArticleStatus::Published, $article->getStatus());
        $this->assertTrue($article->isPublished());
        $this->assertNotNull($article->getPublishedAt());
    }

    public function testPublishRecordsDomainEvent(): void
    {
        $article = $this->createArticle();
        $article->pullDomainEvents(); // Clear creation event

        $article->publish();

        $events = $article->pullDomainEvents();
        $this->assertCount(1, $events);
        $this->assertInstanceOf(ArticlePublished::class, $events[0]);
    }

    public function testCannotPublishAlreadyPublishedArticle(): void
    {
        $article = $this->createArticle();
        $article->publish();

        $this->expectException(InvalidStatusTransition::class);

        $article->publish();
    }

    public function testCannotPublishArchivedArticle(): void
    {
        $article = $this->createArticle();
        $article->archive();

        $this->expectException(InvalidStatusTransition::class);

        $article->publish();
    }

    public function testArchiveFromDraft(): void
    {
        $article = $this->createArticle();

        $article->archive();

        $this->assertSame(ArticleStatus::Archived, $article->getStatus());
    }

    public function testArchiveFromPublished(): void
    {
        $article = $this->createArticle();
        $article->publish();

        $article->archive();

        $this->assertSame(ArticleStatus::Archived, $article->getStatus());
    }

    public function testUpdateArticle(): void
    {
        $article = $this->createArticle();
        $newTitle = ArticleTitle::fromString('Updated Title');
        $newContent = ArticleContent::fromString(str_repeat('New content ', 20));

        $article->update($newTitle, $newContent, CategoryId::fromInt(2));

        $this->assertSame('Updated Title', $article->getTitle()->value);
        $this->assertNotNull($article->getUpdatedAt());
    }

    private function createArticle(): Article
    {
        return Article::create(
            id: ArticleId::generate(),
            title: ArticleTitle::fromString('Test Article'),
            content: ArticleContent::fromString(str_repeat('Test content ', 20)),
            authorId: AuthorId::fromInt(1),
            categoryId: CategoryId::fromInt(1),
        );
    }
}

Testing Value Objects

<?php

declare(strict_types=1);

namespace Tests\Unit\Domain\ValueObject;

use PHPUnit\Framework\TestCase;
use Xoops\GoldStandard\Domain\ValueObject\ArticleTitle;
use Xoops\GoldStandard\Domain\Exception\InvalidArticleTitle;

final class ArticleTitleTest extends TestCase
{
    public function testCreateValidTitle(): void
    {
        $title = ArticleTitle::fromString('Valid Title');

        $this->assertSame('Valid Title', $title->value);
    }

    public function testTrimsWhitespace(): void
    {
        $title = ArticleTitle::fromString('  Title with spaces  ');

        $this->assertSame('Title with spaces', $title->value);
    }

    public function testRejectsEmptyTitle(): void
    {
        $this->expectException(InvalidArticleTitle::class);
        $this->expectExceptionMessage('cannot be empty');

        ArticleTitle::fromString('');
    }

    public function testRejectsTooShortTitle(): void
    {
        $this->expectException(InvalidArticleTitle::class);
        $this->expectExceptionMessage('at least 3 characters');

        ArticleTitle::fromString('AB');
    }

    public function testRejectsTooLongTitle(): void
    {
        $this->expectException(InvalidArticleTitle::class);
        $this->expectExceptionMessage('cannot exceed 255 characters');

        ArticleTitle::fromString(str_repeat('A', 256));
    }

    public function testEquality(): void
    {
        $title1 = ArticleTitle::fromString('Same Title');
        $title2 = ArticleTitle::fromString('Same Title');
        $title3 = ArticleTitle::fromString('Different Title');

        $this->assertTrue($title1->equals($title2));
        $this->assertFalse($title1->equals($title3));
    }

    /**
     * @dataProvider validTitlesProvider
     */
    public function testAcceptsValidTitles(string $input): void
    {
        $title = ArticleTitle::fromString($input);

        $this->assertSame(trim($input), $title->value);
    }

    public static function validTitlesProvider(): array
    {
        return [
            'minimum length' => ['ABC'],
            'with numbers' => ['Article 123'],
            'with special chars' => ['Article: A Test!'],
            'unicode' => ['日本語のタイトル'],
            'maximum length' => [str_repeat('A', 255)],
        ];
    }
}

Testing Command Handlers

<?php

declare(strict_types=1);

namespace Tests\Unit\Application\Command;

use PHPUnit\Framework\TestCase;
use Xoops\GoldStandard\Application\Command\{CreateArticle, CreateArticleHandler};
use Xoops\GoldStandard\Domain\Repository\ArticleRepository;
use Xoops\GoldStandard\Domain\ValueObject\ArticleId;
use Psr\EventDispatcher\EventDispatcherInterface;

final class CreateArticleHandlerTest extends TestCase
{
    private ArticleRepository $repository;
    private EventDispatcherInterface $dispatcher;
    private CreateArticleHandler $handler;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(ArticleRepository::class);
        $this->dispatcher = $this->createMock(EventDispatcherInterface::class);
        $this->handler = new CreateArticleHandler($this->repository, $this->dispatcher);
    }

    public function testCreatesArticle(): void
    {
        $articleId = ArticleId::generate();

        $this->repository
            ->method('nextIdentity')
            ->willReturn($articleId);

        $this->repository
            ->expects($this->once())
            ->method('save');

        $command = new CreateArticle(
            title: 'Test Article',
            content: str_repeat('Content ', 20),
            categoryId: 1,
            authorId: 1,
        );

        $result = ($this->handler)($command);

        $this->assertTrue($articleId->equals($result));
    }

    public function testDispatchesDomainEvents(): void
    {
        $this->repository
            ->method('nextIdentity')
            ->willReturn(ArticleId::generate());

        $this->dispatcher
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->isInstanceOf(ArticleCreated::class));

        $command = new CreateArticle(
            title: 'Test Article',
            content: str_repeat('Content ', 20),
            categoryId: 1,
            authorId: 1,
        );

        ($this->handler)($command);
    }
}

Integration Tests

Testing Repositories

<?php

declare(strict_types=1);

namespace Tests\Integration\Repository;

use Tests\Support\TestCase\IntegrationTestCase;
use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\Entity\ArticleStatus;
use Xoops\GoldStandard\Infrastructure\Persistence\XoopsArticleRepository;

final class XoopsArticleRepositoryTest extends IntegrationTestCase
{
    private XoopsArticleRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = $this->getContainer()->get(XoopsArticleRepository::class);
        $this->truncateTable('goldstandard_articles');
    }

    public function testSaveAndRetrieve(): void
    {
        $article = $this->createArticle('Test Article');

        $this->repository->save($article);

        $found = $this->repository->find($article->id);

        $this->assertNotNull($found);
        $this->assertTrue($article->id->equals($found->id));
        $this->assertSame('Test Article', $found->getTitle()->value);
    }

    public function testFindByStatus(): void
    {
        $draft = $this->createArticle('Draft Article');
        $published = $this->createArticle('Published Article');
        $published->publish();

        $this->repository->save($draft);
        $this->repository->save($published);

        $results = $this->repository->findByStatus(ArticleStatus::Published);

        $this->assertCount(1, $results);
        $this->assertSame('Published Article', $results[0]->getTitle()->value);
    }

    public function testCountByStatus(): void
    {
        $this->repository->save($this->createArticle('Draft 1'));
        $this->repository->save($this->createArticle('Draft 2'));

        $published = $this->createArticle('Published');
        $published->publish();
        $this->repository->save($published);

        $this->assertSame(2, $this->repository->countByStatus(ArticleStatus::Draft));
        $this->assertSame(1, $this->repository->countByStatus(ArticleStatus::Published));
    }

    public function testRemove(): void
    {
        $article = $this->createArticle('To Delete');
        $this->repository->save($article);

        $this->repository->remove($article);

        $this->assertNull($this->repository->find($article->id));
    }

    private function createArticle(string $title): Article
    {
        return Article::create(
            id: $this->repository->nextIdentity(),
            title: ArticleTitle::fromString($title),
            content: ArticleContent::fromString(str_repeat('Content ', 20)),
            authorId: AuthorId::fromInt(1),
            categoryId: CategoryId::fromInt(1),
        );
    }
}

Functional Tests

Testing API Endpoints

<?php

declare(strict_types=1);

namespace Tests\Functional\Api;

use Tests\Support\TestCase\FunctionalTestCase;

final class ArticleEndpointTest extends FunctionalTestCase
{
    public function testListArticles(): void
    {
        // Arrange
        $this->createArticles(5);

        // Act
        $response = $this->get('/api/v1/articles');

        // Assert
        $this->assertResponseOk($response);
        $this->assertJsonStructure($response, [
            'data' => ['*' => ['id', 'type', 'attributes']],
            'meta' => ['total', 'per_page'],
        ]);
    }

    public function testCreateArticle(): void
    {
        $this->actingAs($this->createUser());

        $response = $this->postJson('/api/v1/articles', [
            'title' => 'New Article',
            'content' => str_repeat('Content ', 20),
            'category_id' => 1,
        ]);

        $this->assertResponseCreated($response);
        $this->assertDatabaseHas('goldstandard_articles', [
            'title' => 'New Article',
        ]);
    }

    public function testCreateArticleValidation(): void
    {
        $this->actingAs($this->createUser());

        $response = $this->postJson('/api/v1/articles', [
            'title' => 'AB', // Too short
            'content' => 'Short', // Too short
        ]);

        $this->assertResponseUnprocessable($response);
        $this->assertJsonValidationErrors($response, ['title', 'content', 'category_id']);
    }

    public function testUpdateArticle(): void
    {
        $this->actingAs($user = $this->createUser());
        $article = $this->createArticle(['author_id' => $user->id]);

        $response = $this->putJson("/api/v1/articles/{$article->id}", [
            'title' => 'Updated Title',
        ]);

        $this->assertResponseOk($response);
        $this->assertDatabaseHas('goldstandard_articles', [
            'id' => $article->id->toString(),
            'title' => 'Updated Title',
        ]);
    }

    public function testDeleteArticle(): void
    {
        $this->actingAs($user = $this->createAdmin());
        $article = $this->createArticle();

        $response = $this->delete("/api/v1/articles/{$article->id}");

        $this->assertResponseNoContent($response);
        $this->assertDatabaseMissing('goldstandard_articles', [
            'id' => $article->id->toString(),
        ]);
    }

    public function testUnauthorizedAccess(): void
    {
        $response = $this->postJson('/api/v1/articles', [
            'title' => 'Test',
        ]);

        $this->assertResponseUnauthorized($response);
    }
}

Test Factories

<?php

declare(strict_types=1);

namespace Tests\Support\Factory;

use Xoops\GoldStandard\Domain\Entity\Article;
use Xoops\GoldStandard\Domain\ValueObject\{ArticleId, ArticleTitle, ArticleContent, AuthorId, CategoryId};

final class ArticleFactory
{
    private ?string $title = null;
    private ?string $content = null;
    private ?int $authorId = null;
    private ?int $categoryId = null;
    private bool $published = false;

    public static function new(): self
    {
        return new self();
    }

    public function withTitle(string $title): self
    {
        $clone = clone $this;
        $clone->title = $title;
        return $clone;
    }

    public function withAuthor(int $authorId): self
    {
        $clone = clone $this;
        $clone->authorId = $authorId;
        return $clone;
    }

    public function published(): self
    {
        $clone = clone $this;
        $clone->published = true;
        return $clone;
    }

    public function create(): Article
    {
        $article = Article::create(
            id: ArticleId::generate(),
            title: ArticleTitle::fromString($this->title ?? 'Test Article ' . uniqid()),
            content: ArticleContent::fromString($this->content ?? str_repeat('Content ', 20)),
            authorId: AuthorId::fromInt($this->authorId ?? 1),
            categoryId: CategoryId::fromInt($this->categoryId ?? 1),
        );

        if ($this->published) {
            $article->publish();
        }

        return $article;
    }
}

// Usage
$article = ArticleFactory::new()
    ->withTitle('My Article')
    ->withAuthor(5)
    ->published()
    ->create();

Running Tests

# Run all tests
composer test

# Run with coverage
composer test:coverage

# Run specific suite
./vendor/bin/phpunit --testsuite=Unit
./vendor/bin/phpunit --testsuite=Integration
./vendor/bin/phpunit --testsuite=Functional

# Run specific test
./vendor/bin/phpunit --filter=testCreateArticle

# Run with mutation testing
composer test:mutation

Test Metrics

pie title Test Coverage Goals
    "Unit Tests" : 70
    "Integration Tests" : 25
    "Functional Tests" : 5
Metric Target Current
Line Coverage ≥90% 94%
Branch Coverage ≥85% 88%
Mutation Score ≥80% 82%
Test Execution Time <30s 22s


testing #phpunit #quality #tdd #goldstandard