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

  1. Unit
  2. Integration
  3. Acceptance 
  4. End to End
  5. 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,587