Tactical Patterns for testable applications
Barry O Sullivan - 2020
@barryosull
Who am i?
- Web Developer
- Contractor/Architect/Consultant
- Specialise in Legacy Web Apps
- Insanely good at Mario Kart
- PHP Dublin Organiser
- DDD IE Organiser
@barryosull
Barry O Sullivan
Talk Structure
- Why Test (just in case)
- Writing "Good" Tests
- Strategic Patterns
- Tactical Patterns
- Conclusion (shocker!)
My history with testing
A tumultuous relationship
Why test?
Its saves time & MOney
living Documentation
Test are granular behavioural specifications
that you can also run
better design
Testing an interface gives you immediate feedback
Writing "good" tests
effective Testing
- Iterative
- Easy to change
- Easy to maintain
- Reduced bugs
- Fast tests
- Confidence!!!
Ineffective testing
- Tests take forever to write
- More tests than code
- Brittle tests and code
- Way too many objects . . .
- Slow tests
- Anxiety (Why is it all RED?!!?)
They are
Behaviour focussed
Module driven design
Only external interfaces need to be tested
DB/Network/Filesystem
is fine (if it is fast)
Mock appropriately
Classes extracted from a class do not need tests
They are noT
Method focussed
Class driven design
Every class needs its own test cases
No DB/Network/FileSystem
access at all
Mock every dependency
Classes extracted from a class needs tests
Common Misconceptions
Unit tests
Strategies
for testing
Describe what you want it to do;
Not how you're going to do it.
Focus on Behaviour
MODULES
UNIT Test modules, not classes
Module
Sub Module
Sub Module
Library Module
Potential Boundary for a
Stub/Mock/Fake
types of tests
- Unit
- Integration
- Acceptance
- End to End
- Manual
Easy/Fast
Complicated/Slow
Migrate to these
(Most important)
Start with
these
Avoid these
Write Test
Write Code
Refactor Test
Refactor
Code
iteration
You won't get it right first time
Tactics
for testing
AcceptancE tests
HTTP RequestIN
HTTP Response Out
Database Operations
Message Queue
Behavioural tests for the application
<?php
namespace OSSTests\Auth\Acceptance;
class UserLoginTest extends \PHPUnit\Framework\TestCase
{
/** @var GuzzleHttp\Client */
private $client;
public function test_a_user_can_login()
{
$this->givenThereIsAUser();
$request = $this->makeLoginUserRequest();
$response = $this->client->send($request);
$this->assertUserHasLoginCookie($response);
}
private function givenThereIsAUser(): void
{
// Insert user into database
}
private function makeLoginUserRequest(): RequestInterface
{
// Make a POST request to login a user
}
private function assertUserHasLoginCookie(ResponseInterface $response)
{
// Check session to see if the user is there
}
}
Acceptance test
Dependency injection
Testing
Staging
Production
ports and adapters
HTTP
Adapters
Application
Repositories
External Services
HTTP adapter Test
<?php
namespace OSSTests\Auth\Unit\Http\Controllers;
Use OSS\Auth\Controllers\UserController;
use OSSTest\Auth\Support\UserRequestFactory;
use OSSTest\Auth\Support\UserCommandFactory;
use OSSTest\Auth\App\CommandHandlerFake;
class UserRegisterTest extends \PHPUnit\Framework\TestCase
{
public function test_creates_register_user_command()
{
$httpRequest = UserRequestFactory::makeRegisterUser();
$commandHandler = new CommandHandlerFake();
$adapter = new UserController($commandHandler);
$adapter->handle($request);
$expectedCommand = UserCommandFactory::makeRegisterUserCommand();
$actualCommand = $commandHandler->handledCommand();
$this->assertEquals($expectedCommand, $actualCommand, "Commands do not match");
}
}
Command handler (App)
Issue Commands to your application
Application
Command
Success /
Failure
Repositories
External
Services
Stubs/Mocks/Fakes
Stubs/Mocks/Fakes
Value Object
Encapsulate value guarding
<?php
namespace OSS\Auth\App\ValueObjects;
final class Email
{
private $value;
public function __construct(string $email)
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new DomainException("'{$email}' is not a valid email address");
}
$this->value = $email;
}
public function __toString(): string
{
return $this->value;
}
}
- Represent values
- Simple classes
- Cannot instantiate an invalid value
- Immutable
- Value constraints now easy to test
Value Object Tests
<?php
namespace OSSTests\Auth\App\ValueObjects;
Use OSS\Auth\ValueObjects\Email;
Use OSS\Auth\ValueObjects\ValueError;
class EmailTest extends \PHPUnit\Framework\TestCase
{
public function test_accepts_valid_emails()
{
$validEmails = ['email@email.com', 'fgsfg@fgfd2234.com', 'foo+bar@baz.com'];
foreach($validEmails as $validEmail) {
$email = new Email($validEmail);
}
}
/**
* @dataProvider invalidEmails
*/
public function test_rejects_invalid_emails($invalidEmail)
{
$this->expectException(ValueError::class);
$email = new Email($invalidEmail);
}
private function invalidEmails(): array
{
return [['qux.baz.com'], ['foo@bar@baz.com'], ['.foo@baz.com']];
}
}
Composite Vos
<?php
namespace OSS\Auth\App\Commands;
final class ChangeEmailCommand
{
private $userId;
private $email;
public function __construct(UserId $userId, Email $email)
{
$this->userId = $userId;
$this->email = $email;
}
public function getUserId(): UserId
{
return $this->userId;
}
public function getEmail(): Email
{
return $this->email;
}
}
Compose ValueObjects to represent concepts
(e.g. Commands)
repositories
Encapsulate object persistence
repository test
<?php
namespace OSSTests\Auth\Infra\Repositories;
Use OSS\Auth\App\Repositories\UserRepository;
use OSSTests\Auth\Support\UserFactory;
abstract class UserRepostioryTest extends \PHPUnit\Framework\TestCase
{
public function test_can_fetch_a_stored_user()
{
$user = UserFactory::make();
$repo = $this->makeRepo();
$repo->store($user);
$actual = $repo->fetch($user->getId());
$this->assertEquals($user, $actual);
}
abstract protected function makeRepo(): UserRepository;
}
Concrete integration test would extend this test and make the `makeRepo()` method return a real instance
API INTEGRATIONS
Service
Interface
App
HTTP
Client
Testing Difficult APIs:
- Manually test API
- Store sample Requests/Responses
- Use as fixtures in Fake HTTP Client
- Inject Fake into Service Interface during tests
Dont control, can't test
Do control,
can fake
Stubs/mocks/Fakes
Bad COmposition tests
- Easy to misuse
- Implementation specific (brittle)
- Rely heavily mocking libraries
- Behaviour isn't tested
- Most of the time they're useless
<?php
....
protected function setUp()
{
$this->engineFactory = Phake::mock('OSS\CacheEngine\CacheDriverFactory');
$this->xcacheMock = Phake::mock('OSS\CacheEngine\XcacheEngine');
$this->memcachedMock = Phake::mock('OSS\CacheEngine\MemcachedEngine');
$this->inMemoryMock = Phake::mock('OSS\CacheEngine\InMemoryEngine');
Phake::when($this->engineFactory)->getCacheDriver(new CacheType(Cacher::SHARED_CACHE))
->thenReturn($this->memcachedMock);
Phake::when($this->engineFactory)->getCacheDriver(new CacheType(Cacher::SESSION_CACHE))
->thenReturn($this->memcachedMock);
Phake::when($this->engineFactory)->getCacheDriver(new CacheType(Cacher::LOCAL_CACHE))
->thenReturn($this->xcacheMock);
Phake::when($this->engineFactory)->getCacheDriver(new CacheType(Cacher::IN_MEMORY_CACHE))
->thenReturn($this->inMemoryMock);
}
test support code
Factories
Encapsulate object creation
- Reduce waste
- Use "create/make" keyword
- Wait for duplication
- Extract into classes
- Name key states
- Use as test glue
Builders
When factories aren't enough
<?php
namespace OSSTests\Auth\Support;
class UserBuilder
{
private $lastLogin = null;
private $userName;
public function __construct()
{
$this->username = new UserName("testuser");
// Details omitted
}
public function withLastLogin(DateTime $lastLogin): self
{
$builder = clone $this;
$builder->lastLogin = $lastLogin;
return builder;
}
public function withUserName(UserName $userName): self
{
$builder = clone $this;
$builder->userName = $userName;
return builder;
}
public function build(): User
{
return new User($this->username, ..., ..., $this->lastLogin);
}
}
Conclusion
Key points
Tests are runnable documentation
therefore
Focus on describing behaviour
- Test modules, not individual classes
- Keep it simple
- Break up modules when testing gets difficult
- Choose the right pattern for the job
More info
Q & A
Barry O Sullivan - 2020
@barryosull
Tactical Patterns for Testable Applications
By Barry O' Sullivan
Tactical Patterns for Testable Applications
Testing is core to being a professional developer, it's one of main tools we use to design systems and prove they work. The thing is though, it's tricky to do. Tests are code and code must be maintained. So how do you test your code in a way that doesn't hamper change? That's the focus of this talk, a series of tactical design patterns that can applied to write testable applications.
- 1,744