SOLID Unit Testing
By Korvin Szanto
Unit
Testing
Input →【 Behavior 】→ Output
Unit Test
【 System 2 】
Integration Test
System 1
Input →
→Output
⤺
⤺
Why?
Where To Start?
-
Probably not TDD
-
If it's an existing project, regression testing
-
If it's a new project, unit testing
ingle responsibility
pen / closed
iskov substitution
nterface segregation
ependency inversion
SOLID
SOLID
Single Responsibility
"A class should have only one reason to change"
class Employee {
/**
* Save the employee record to the database
*/
public function save(): void
{
$connection = Database::connection();
$connection->execute('insert ignore into ...');
}
/**
* Calculate the amount to pay the employee
*/
public function calculatePay(): float
{
return $this->getHoursWorked() * 15.00;
}
/**
* Calculate the hours the employee worked
*/
public function getHoursWorked(): int
{
return $this->regularHours + ($this->overtimeHours * 1.5);
}
}
class Employee {
/**
* Save the employee record to the database
*/
public function save(): void
{
$this->employeeSaver->save($this);
}
/**
* Calculate the amount to pay the employee
*/
public function calculatePay(): Pay
{
return $this->payCalculator->calculate($this->getHoursWorked());
}
/**
* Calculate the hours the employee worked
*/
public function getHoursWorked(): int
{
return $this->hoursCalculator->getHoursWorked($this);
}
}
class EmployeeService {
/**
* Save the employee record to the database
*/
public function save(Employee $employee): void
{
$this->employeeSaver->save($employee);
}
/**
* Calculate the amount to pay the employee
*/
public function calculatePay(Employee $employee): Pay
{
return $this->payCalculator->calculate($employee, $this->getHoursWorked($employee));
}
/**
* Calculate the hours the employee worked
*/
public function getHoursWorked(Employee $employee): int
{
return $this->hoursCalculator->getHoursWorked($employee);
}
}
class EmployeeServiceTest extends TestCase
{
public function testHoursCalculation()
{
// Prepare our test doubles
$fakeEmployee = M::mock(Employee::class);
$fakeCalculator = M::mock(HoursCalculator::class);
// Set up our calculator behaviors
$fakeCalculator
->shouldReceive('getHoursWorked')
->withArgs([$fakeEmployee])
->andReturn(1337);
// Run our unit of functionality
$employeeService = new EmployeeService($fakeCalculator);
$calculatedPay = $employeeService->getHoursWorked($employee);
// Validate the output and make sure it's what we expect
$this->assertEquals(1337, $calculatedPay);
}
}
Open / Closed
"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"
Extension
Modification
class Bot { |
function connect() |
function run() |
function stop() |
} |
class BotFactory { |
function createBot() |
function getCommands() |
} |
class HelpCommand { |
protected $signature |
protected $name |
function run() |
} |
class InfoCommand { |
protected $signature |
protected $name |
function run() |
} |
class TimeCommand { |
protected $signature |
protected $name |
function run() |
} |
class Bot { |
function connect() |
function run() |
function stop() |
} |
class BotFactory { |
function createBot() |
function getCommands() |
} |
class HelpCommand { |
protected $signature |
protected $name |
function run() |
} |
class InfoCommand { |
protected $signature |
protected $name |
function run() |
} |
class TimeCommand { |
protected $signature |
protected $name |
function run() |
} |
class Bot { |
function connect() |
function run() |
function stop() |
} |
class BotFactory { |
function createBot() |
function getCommands() |
} |
class HelpCommand { |
protected $signature |
protected $name |
function run() |
} |
class InfoCommand { |
protected $signature |
protected $name |
function run() |
} |
YAGNI
Liskov Substitution
"If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S"
class Car
class Bus extends Car
class DuckBus extends Bus
Interface Segregation
"No client should be forced to depend on methods it does not use"
vs
abstract class Vehicle
{
public function getCoordinates()
{
...
}
public function getSpeed()
{
...
}
public function getDirection()
{
...
}
}
interface Piloted {
public function getPilot();
}
interface Wheeled {
public function getWheels();
}
interface HasPassengers {
public function getPassengers();
}
class Car extends Vehicle implements Piloted, HasPassengers, Wheeled
{
...
}
interface Flying {
public function getAttitude();
public function getAltitude();
}
interface HasPropeller {
public function getThrust();
public function getThrustVector();
}
interface Floating {
public function getBuoyancy();
}
interface Underwater {
public function getDepth();
}
trait FlyingVehicleTest
{
/**
* @dataProvider flyingVehicleProvider
*/
public function testFlyingSettersGetters(Flying $vehicle)
{
$vehicle->setAzimuth(0);
$this->assertEquals(0, $vehicle->getAzimuth());
}
}
class PlaneTest extends TestCase
{
use FlyingVehicleTrait;
public function flyingVehicleProvider()
{
return [new Plane()];
}
....
}
class FloatingVehicleTest extends TestCase
{
/**
* @dataProvider flyingVehicleProvider
*/
public function testFloatingVehicle(Floating $vehicle)
{
...
}
public function flyingVehicleProvider()
{
return [
new Boat(),
new CommercialPlane(),
new Dinghy(),
new DragonBoat(),
];
}
}
Dependency Inversion
"Depend upon abstractions, not concretions."
class FormSubmissionController extends Controller
{
public function handleFormSubmission(): Response
{
// Resolve the request object
$request = Request::createFromGlobals();
// Handle the form submission request
$formService = new FormService();
$formResult = $formService->handleRequest($request);
// Turn our formResult into a Response
$responseFactory = new ResponseFactory();
$response = $responseFactory->createFromFormResult($formResult);
// Output our response
return $response;
}
}
class FormSubmissionControllerTest extends TestCase
{
public function testHandlingFormSubmissions()
{
// Run test
$controller = new FormSubmissionController();
$response = $controller->handleFormSubmission();
// Validate output
$this->assertEquals('...', $response->getBody());
$this->assertEquals(200, $response->getCode());
}
}
class FormSubmissionController extends Controller
{
protected $formService;
protected $responseFactory;
public function __construct(FormService $formService, ResponseFactory $responseFactory)
{
$this->formService = $formService;
$this->responseFactory = $responseFactory;
}
public function handleFormSubmission(Request $request): Response
{
// Handle the form submission request
$formResult = $this->formService->handleRequest($request);
// Turn our formResult into a Response
$response = $this->responseFactory->createFromFormResult($formResult);
// Output our response
return $response;
}
}
class FormSubmissionControllerTest extends TestCase
{
public function testHandlingFormSubmissions()
{
// Create fake dependencies
$formService = M::mock(FormService::class);
$responseFactory = M::mock(ResponseFactory::class);
// Create a fake request to pass as input
$request = M::mock(Request::class);
$request->shouldReceive(...)->andReturn(...);
// Create a fake form result for our FormService to return
$result = M::mock(FormResult::class);
$formService
->shouldReceive('getResult')->withArgs([$request])
->andReturn($result);
// Create a fake response for our response factory to return
$response = M::mock(Response::class);
$responseFactory
->shouldReceive('createFromFormResult')->withArgs([$result])
->andReturn($response);
// Run test
$controller = new FormSubmissionController($formService, $responseFactory);
$formResponse = $controller->handleFormSubmission($request);
// Validate output
$this->assertEquals($response, $formResponse);
}
}
class FormSubmissionController extends Controller
{
public function __construct(FormServiceInterface $form, ResponseFactoryInterface $factory)
{
...
}
public function handleFormSubmission(RequestInterface $request): Response
{
...
}
}
class FormSubmissionControllerTest extends TestCase
{
public function testHandlingFormSubmissions()
{
// Create fake dependencies
$formService = M::mock(FormServiceInterface::class);
$responseFactory = M::mock(ResponseFactoryInterface::class);
// Create a fake request to pass as input
$request = M::mock(RequestInterface::class);
$result = M::mock(FormResultInterface::class);
$response = M::mock(ResponseInterface::class);
...
}
}
interface MarkdownInterface
{
public function bold(string $string): string;
public function italic(string $string): string;
public function link(string $string, string $href): string;
}
class MarkdownFormatter implements FormatterInterface
{
public function __construct(MarkdownInterface $markdown)
{
$this->markdown = $markdown;
}
public function process(string $entry)
{
return $this->markdown->bold($markdown);
}
}
class MarkdownFormatterTest extends TestCase
{
public function testProcessing()
{
$testString = 'foobar';
// Mock the markdown dependency
$fakeMarkdown = M::mock(MarkdownInterface::class);
$fakeMarkdown
->shouldReceive('bold')->withArgs([$testString])
->andReturn('worked');
// Run the test
$formatter = new MarkdownFormatter($fakeMarkdown);
$output = $formatter->process($testString);
// Make sure our output passed through as expected
$this->assertEquals('worked', $output);
}
}
github.com/korvinszanto
@korvinszanto
php.ug/slackinvite
SOLID Unit Testing
By Korvin Szanto
github.com/korvinszanto
@korvinszanto
php.ug/slackinvite
https://joind.in/talk/4691d
SOLID Unit Testing
By Korvin Szanto
SOLID Unit Testing
- 988