🧪 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 |