Unit tests on application services

This commit is contained in:
theUniC
2022-07-31 18:40:22 +02:00
parent f9c3b66aed
commit 0770c2e2ed
16 changed files with 496 additions and 97037 deletions

View File

@@ -1,20 +0,0 @@
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@2.16.0
with:
php-version: 8.1
extensions: xdebug, redis, amqp
- name: Composer install
run: php composer.phar install --ignore-platform-req=php
- name: Run tests
run: php composer.phar unit-tests
- name: Run Infection
run: php infection.phar --min-msi=80 --min-covered-msi=70 --threads=4 --show-mutations --only-covered

13
.gitignore vendored
View File

@@ -7,14 +7,9 @@
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
###> application ###
infection.log
coverage
###< application ###
###> php-cs-fixer ###
.php-cs-fixer.cache
@@ -27,4 +22,8 @@ infection.log
!.yarn/releases
!.yarn/sdks
!.yarn/versions
###< yarn ###
###< yarn ###
###> symfony/phpunit-bridge ###
.phpunit.result.cache
/phpunit.xml
###< symfony/phpunit-bridge ###

19
bin/phpunit Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

File diff suppressed because one or more lines are too long

View File

@@ -51,9 +51,11 @@
"phpunit/phpunit": "^9.0",
"roave/security-advisories": "dev-latest",
"symfony/browser-kit": "6.0.*",
"symfony/css-selector": "6.0.*",
"symfony/debug-bundle": "6.0.*",
"symfony/maker-bundle": "^1.14",
"symfony/monolog-bundle": "^3.0",
"symfony/phpunit-bridge": "^6.1",
"symfony/stopwatch": "6.0.*",
"symfony/web-profiler-bundle": "6.0.*",
"theofidry/psysh-bundle": "^4.3"

150
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "956cf7681153f66649fb80d9594534a4",
"content-hash": "f213d9744da79d8e4893aea0fcce90d0",
"packages": [
{
"name": "beberlei/assert",
@@ -10074,6 +10074,71 @@
],
"time": "2022-01-02T09:55:41+00:00"
},
{
"name": "symfony/css-selector",
"version": "v6.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "ab2746acddc4f03a7234c8441822ac5d5c63efe9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/ab2746acddc4f03a7234c8441822ac5d5c63efe9",
"reference": "ab2746acddc4f03a7234c8441822ac5d5c63efe9",
"shasum": ""
},
"require": {
"php": ">=8.0.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v6.0.11"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2022-06-27T17:10:44+00:00"
},
{
"name": "symfony/debug-bundle",
"version": "v6.0.3",
@@ -10477,6 +10542,89 @@
],
"time": "2021-11-05T10:34:29+00:00"
},
{
"name": "symfony/phpunit-bridge",
"version": "v6.1.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/phpunit-bridge.git",
"reference": "75c2fa71d049c1f48e39d208c0cefba97e66335a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/75c2fa71d049c1f48e39d208c0cefba97e66335a",
"reference": "75c2fa71d049c1f48e39d208c0cefba97e66335a",
"shasum": ""
},
"require": {
"php": ">=7.1.3"
},
"conflict": {
"phpunit/phpunit": "<7.5|9.1.2"
},
"require-dev": {
"symfony/deprecation-contracts": "^2.1|^3.0",
"symfony/error-handler": "^5.4|^6.0"
},
"suggest": {
"symfony/error-handler": "For tracking deprecated interfaces usages at runtime with DebugClassLoader"
},
"bin": [
"bin/simple-phpunit"
],
"type": "symfony-bridge",
"extra": {
"thanks": {
"name": "phpunit/phpunit",
"url": "https://github.com/sebastianbergmann/phpunit"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Bridge\\PhpUnit\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides utilities for PHPUnit, especially user deprecation notices management",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/phpunit-bridge/tree/v6.1.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2022-07-28T13:40:41+00:00"
},
{
"name": "symfony/web-profiler-bundle",
"version": "v6.0.6",

View File

@@ -30,23 +30,22 @@ final class FollowApplicationService
public function countFollowersOf(string $authorId): int
{
$authorId = AuthorId::fromString($authorId);
$this->tryToFindAuthor($authorId);
if (null === $this->authorRepository->ofId($authorId)) {
throw AuthorDoesNotExist::withAuthorIdOf($authorId);
}
return $this->followRepository->numberOfFollowersFor($authorId);
return $this->followRepository->numberOfFollowersFor(
AuthorId::fromString($authorId)
);
}
private function tryToFindAuthor(string $authorId): Author
{
$fromAuthor = $this->authorRepository->ofId(AuthorId::fromString($authorId));
$id = AuthorId::fromString($authorId);
$author = $this->authorRepository->ofId($id);
if (null === $fromAuthor) {
throw AuthorDoesNotExist::withAuthorIdOf(AuthorId::fromString($authorId));
if (null === $author) {
throw AuthorDoesNotExist::withAuthorIdOf($id);
}
return $fromAuthor;
return $author;
}
}

View File

@@ -15,18 +15,6 @@ interface CheepRepository
/** @return Cheep[] */
public function all(): array;
/** @return Cheep[] */
public function allBetween(DateTimeInterface $from, DateTimeInterface $to): array;
/** @return Cheep[] */
public function allGroupedByMonthAndYear(): array;
/** @return Cheep[] */
public function ofFollowersOfAuthor(Author $author): array;
/** @return Cheep[] */
public function groupedByMonth(int $year): array;
/** @return Cheep[] */
public function ofFollowingPeopleOf(Author $author, int $offset, int $size): array;
}

View File

@@ -34,26 +34,6 @@ final class DoctrineOrmCheepRepository implements CheepRepository
return $this->em->getRepository(Cheep::class)->findAll();
}
public function allBetween(DateTimeInterface $from, DateTimeInterface $to): array
{
// TODO: Implement allBetween() method.
}
public function allGroupedByMonthAndYear(): array
{
// TODO: Implement allGroupedByMonthAndYear() method.
}
public function ofFollowersOfAuthor(Author $author): array
{
// TODO: Implement ofFollowersOfAuthor() method.
}
public function groupedByMonth(int $year): array
{
// TODO: Implement groupedByMonth() method.
}
public function ofFollowingPeopleOf(Author $author, int $offset, int $size): array
{
$dql = <<<DQL

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Cheeper\Infrastructure\Persistence;
use Cheeper\DomainModel\Author\AuthorId;
use Cheeper\DomainModel\Follow\Follow;
use Cheeper\DomainModel\Follow\FollowRepository;
final class InMemoryFollowRepository implements FollowRepository
{
/** @var Follow[] */
private array $follows = [];
public function numberOfFollowersFor(AuthorId $authorId): int
{
return count(
array_filter(
$this->follows,
static fn(Follow $f) => $f->toAuthorId()->equals($authorId)
)
);
}
public function add(Follow $follow): void
{
$this->follows[] = $follow;
}
public function fromAuthorIdAndToAuthorId(AuthorId $fromAuthorId, AuthorId $toAuthorId): ?Follow
{
$candidates = array_filter(
$this->follows,
static fn(Follow $f) => $f->fromAuthorId()->equals($fromAuthorId) && $f->toAuthorId()->equals($toAuthorId)
);
return current($candidates);
}
public function toAuthorId(AuthorId $authorId): array
{
return array_filter(
$this->follows,
static fn(Follow $f) => $f->toAuthorId()->equals($authorId)
);
}
}

View File

@@ -330,6 +330,9 @@
"config/bootstrap.php"
]
},
"symfony/css-selector": {
"version": "v6.0.11"
},
"symfony/debug-bundle": {
"version": "4.1",
"recipe": {
@@ -448,6 +451,21 @@
"symfony/password-hasher": {
"version": "v5.3.7"
},
"symfony/phpunit-bridge": {
"version": "6.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96"
},
"files": [
".env.test",
"bin/phpunit",
"phpunit.xml.dist",
"tests/bootstrap.php"
]
},
"symfony/polyfill-intl-grapheme": {
"version": "v1.17.0"
},

View File

@@ -16,6 +16,15 @@ use Ramsey\Uuid\Uuid;
final class AuthorApplicationServiceTest extends TestCase
{
private AuthorApplicationService $authorApplicationService;
private AuthorRepository $authorRepository;
protected function setUp(): void
{
$this->authorRepository = new InMemoryAuthorRepository();
$this->authorApplicationService = new AuthorApplicationService($this->authorRepository);
}
/** @test */
public function givenAuthorSignUpWhenAuthorUsernameIsAlreadyPickedUpThenAnExceptionShouldBeThrown(): void
{
@@ -30,13 +39,11 @@ final class AuthorApplicationServiceTest extends TestCase
$website = 'https://google.com';
$birthDate = (new \DateTimeImmutable())->format('Y-m-d');
$authorRepository = new InMemoryAuthorRepository();
$authorRepository->add(
$this->authorRepository->add(
AuthorTestDataBuilder::anAuthor()->build()
);
$authorApplicationService = new AuthorApplicationService($authorRepository);
$authorApplicationService->signUp(
$this->authorApplicationService->signUp(
$id,
$username,
$email,
@@ -60,10 +67,7 @@ final class AuthorApplicationServiceTest extends TestCase
$website = 'https://google.com';
$birthDate = (new \DateTimeImmutable())->format('Y-m-d');
$authorRepository = new InMemoryAuthorRepository();
$authorApplicationService = new AuthorApplicationService($authorRepository);
$authorApplicationService->signUp(
$this->authorApplicationService->signUp(
$id,
$username,
$email,
@@ -75,7 +79,7 @@ final class AuthorApplicationServiceTest extends TestCase
);
$this->assertNotNull(
$authorRepository->ofUserName(UserName::pick('irrelevant'))
$this->authorRepository->ofUserName(UserName::pick('irrelevant'))
);
}
}

View File

@@ -11,21 +11,27 @@ use Cheeper\DomainModel\Author\AuthorId;
use Cheeper\DomainModel\Author\AuthorRepository;
use Cheeper\DomainModel\Author\EmailAddress;
use Cheeper\DomainModel\Author\UserName;
use Cheeper\DomainModel\Cheep\Cheep;
use Cheeper\DomainModel\Cheep\CheepId;
use Cheeper\DomainModel\Cheep\CheepMessage;
use Cheeper\DomainModel\Cheep\CheepRepository;
use Cheeper\Infrastructure\Persistence\InMemoryAuthorRepository;
use Cheeper\Tests\DomainModel\Author\AuthorTestDataBuilder;
use Cheeper\Tests\DomainModel\Cheep\CheepTestDataBuilder;
use Mockery;
use PHPUnit\Framework\TestCase;
final class CheepApplicationServiceTest extends TestCase
{
private CheepRepository $cheeps;
private CheepRepository $cheepRepository;
private AuthorRepository $authorRepository;
private CheepApplicationService $cheepService;
public function setUp(): void
{
$this->cheeps = Mockery::mock(CheepRepository::class);
$this->authorRepository = Mockery::mock(AuthorRepository::class);
$this->cheepService = new CheepApplicationService($this->authorRepository, $this->cheeps);
$this->cheepRepository = Mockery::mock(CheepRepository::class);
$this->authorRepository = new InMemoryAuthorRepository();
$this->cheepService = new CheepApplicationService($this->authorRepository, $this->cheepRepository);
}
/** @test */
@@ -33,16 +39,17 @@ final class CheepApplicationServiceTest extends TestCase
{
$this->expectException(AuthorDoesNotExist::class);
$this->authorRepository->allows('ofUserName')->andReturns(null);
$this->cheepService->postCheep('irrelevant', 'irrelevant');
}
/** @test */
public function itShouldAddCheep(): void
{
$this->authorRepository->allows('ofUsername')->andReturns(self::anAuthor());
$this->cheeps->expects('add');
$this->authorRepository->add(
AuthorTestDataBuilder::anAuthor()->build()
);
$this->cheepRepository->expects('add');
$cheep = $this->cheepService->postCheep('irrelevant', 'message');
@@ -51,12 +58,32 @@ final class CheepApplicationServiceTest extends TestCase
$this->assertEquals('message', $cheep->cheepMessage()->message());
}
private static function anAuthor(): Author
/** @test */
public function givenATimelineRequestWhenTheAuthorDoesNotExistThenAnExceptionShouldBeThrown(): void
{
return Author::signUp(
AuthorId::nextIdentity(),
UserName::pick('irrelevant'),
EmailAddress::from('test@gmail.com')
$this->expectException(AuthorDoesNotExist::class);
$this->cheepService->timelineFrom(AuthorTestDataBuilder::anAuthorIdentity()->toString(), 0, 1);
}
/** @test */
public function givenATimelineRequestWhenExecutionGoesWellThenAListOfCheepsShouldBeReturned(): void
{
$author = AuthorTestDataBuilder::anAuthor()->build();
$this->authorRepository->add($author);
$cheeps = [
CheepTestDataBuilder::aCheep()->withAMessage("test1"),
CheepTestDataBuilder::aCheep()->withAMessage("test2"),
CheepTestDataBuilder::aCheep()->withAMessage("test3"),
];
$this->cheepRepository->allows()->ofFollowingPeopleOf($author, 0, 10)->andReturn($cheeps);
$this->assertCount(
count($cheeps),
$this->cheepService->timelineFrom($author->authorId()->toString(), 0, 10)
);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Cheeper\Tests\Application;
use Cheeper\Application\FollowApplicationService;
use Cheeper\DomainModel\Author\AuthorDoesNotExist;
use Cheeper\DomainModel\Author\AuthorRepository;
use Cheeper\DomainModel\Follow\FollowRepository;
use Cheeper\Infrastructure\Persistence\InMemoryAuthorRepository;
use Cheeper\Infrastructure\Persistence\InMemoryFollowRepository;
use Cheeper\Tests\DomainModel\Author\AuthorTestDataBuilder;
use PHPUnit\Framework\TestCase;
final class FollowApplicationServiceTest extends TestCase
{
private AuthorRepository $authorRepository;
private FollowApplicationService $followApplicationService;
private FollowRepository $followRepository;
protected function setUp(): void
{
$this->authorRepository = new InMemoryAuthorRepository();
$this->followRepository = new InMemoryFollowRepository();
$this->followApplicationService = new FollowApplicationService($this->followRepository, $this->authorRepository);
}
/** @test */
public function givenANonExistingAuthorWhenFollowUseCaseIsExecutedThenAnExceptionShouldBeThrown(): void
{
$this->expectException(AuthorDoesNotExist::class);
$this->followApplicationService->followTo(
AuthorTestDataBuilder::anAuthorIdentity()->toString(),
AuthorTestDataBuilder::anAuthorIdentity()->toString(),
);
}
/** @test */
public function givenANonExistingAuthorToFollowWhenFollowUseCaseIsExecutedThenAnExceptionShouldBeThrown(): void
{
$this->expectException(AuthorDoesNotExist::class);
$author = AuthorTestDataBuilder::anAuthor()->build();
$this->authorRepository->add($author);
$this->followApplicationService->followTo(
$author->authorId()->toString(),
AuthorTestDataBuilder::anAuthorIdentity()->toString(),
);
}
/** @test */
public function givenTwoExistingAuthorsWhenFollowUseCaseIsExecutedThenAnExceptionShouldBeThrown(): void
{
$fromAuthor = AuthorTestDataBuilder::anAuthor()->build();
$toAuthor = AuthorTestDataBuilder::anAuthor()->build();
$this->authorRepository->add($fromAuthor);
$this->authorRepository->add($toAuthor);
$this->followApplicationService->followTo(
$fromAuthor->authorId()->toString(),
$toAuthor->authorId()->toString()
);
$follows = $this->followRepository->toAuthorId($toAuthor->authorId());
$this->assertCount(1, $follows);
}
/** @test */
public function givenFollowersCountForANonExistingAuthorWhenCountIsRequestedThenAnExceptionShouldBeThrown(): void
{
$this->expectException(AuthorDoesNotExist::class);
$this->followApplicationService->countFollowersOf(
AuthorTestDataBuilder::anAuthorIdentity()->toString()
);
}
/** @test */
public function givenFollowersCountWhenCountIsRequestedThenItShouldReturnTheTotalNumberOfFollowers(): void
{
$author = AuthorTestDataBuilder::anAuthor()->build();
$follower1 = AuthorTestDataBuilder::anAuthor()->build();
$follower2 = AuthorTestDataBuilder::anAuthor()->build();
$follower3 = AuthorTestDataBuilder::anAuthor()->build();
$this->authorRepository->add($author);
$this->authorRepository->add($follower1);
$this->authorRepository->add($follower2);
$this->authorRepository->add($follower3);
$this->followApplicationService->followTo($follower1->authorId()->toString(), $author->authorId()->toString());
$this->followApplicationService->followTo($follower2->authorId()->toString(), $author->authorId()->toString());
$this->followApplicationService->followTo($follower3->authorId()->toString(), $author->authorId()->toString());
$totalNumberOfFollowers = $this->followApplicationService->countFollowersOf($author->authorId()->toString());
$this->assertSame(3, $totalNumberOfFollowers);
}
}

View File

@@ -10,6 +10,8 @@ use Cheeper\DomainModel\Author\BirthDate;
use Cheeper\DomainModel\Author\EmailAddress;
use Cheeper\DomainModel\Author\UserName;
use Cheeper\DomainModel\Author\Website;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final class AuthorTestDataBuilder
{
@@ -30,6 +32,19 @@ final class AuthorTestDataBuilder
return new self();
}
public static function anAuthorIdentity(string | UuidInterface | null $anAuthorId = null): AuthorId
{
if ($anAuthorId && is_string($anAuthorId)) {
return AuthorId::fromString($anAuthorId);
}
if ($anAuthorId) {
return AuthorId::fromUuid($anAuthorId);
}
return AuthorId::nextIdentity();
}
public function withUserNameOf(string $userName): self
{
$this->userName = $userName;

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Cheeper\Tests\DomainModel\Cheep;
use Cheeper\DomainModel\Cheep\Cheep;
use Cheeper\DomainModel\Cheep\CheepId;
use Cheeper\DomainModel\Cheep\CheepMessage;
use Cheeper\Tests\DomainModel\Author\AuthorTestDataBuilder;
use Ramsey\Uuid\UuidInterface;
final class CheepTestDataBuilder
{
private string | UuidInterface | null $authorId = null;
private string | UuidInterface | null $cheepId = null;
private string $cheepMessage;
private function __construct()
{
}
public static function aCheepIdentity(string | UuidInterface | null $aCheepId = null): CheepId
{
if ($aCheepId && is_string($aCheepId)) {
return CheepId::fromString($aCheepId);
}
if ($aCheepId) {
return CheepId::fromUuid($aCheepId);
}
return CheepId::nextIdentity();
}
public static function aCheep(): self
{
return new self();
}
public function fromAuthorId(string|UuidInterface $authorId): self
{
$this->authorId = $authorId;
return $this;
}
public function withCheepIdOf(string|UuidInterface $cheepId): self
{
$this->cheepId = $cheepId;
return $this;
}
public function withAMessage(string $message): self
{
$this->cheepMessage = $message;
return $this;
}
public function build(): Cheep
{
return Cheep::compose(
AuthorTestDataBuilder::anAuthorIdentity($this->authorId),
self::aCheepIdentity($this->cheepId),
CheepMessage::write($this->cheepMessage)
);
}
}