SOLID Unit Testing

By Korvin Szanto

Unit

Testing

InputBehavior 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