20-07-2022
Jako developer lubię mieć poczucie bezpieczeństwa wprowadzanych zmian. Niestety niektóre klasy/metody w projektach nie mają do nich napisanych testów. W projektach trafiają się fragmenty kodu do których jest ciężko napisać test bez zrobienia refaktoryzacji. Możemy jednak czasami niskim nakładem pracy zmodyfikować metodę, aby właśnie dało się do niej napisać testy. Dziś rozpatrzymy jedną z takich sytuacji.
Spójrzmy na przykład zamieszczony poniżej. Mamy sobie klasę Legacy która ma publiczną metodę calculateNewPrice. Dla uproszczenia przykładu pominęłam „skomplikowany kod legacy” komentarzami i zostawiłam jedną linię z tworzeniem obiektu klasy CustomerDiscountRepository (która w zamyśle nawiązuje połączenie z bazą danych).
class Legacy { public function __construct( private readonly PartyIdentifier $identifier ) { } public function calculateNewPrice(Money $currentPrice, \DateTime $date): Money { // dużoooo legacy $repository = new CustomerDiscountRepository(); // jeszcze więcej legacy ... return $newPrice ?? $currentPrice; } }
Jaki mamy tutaj problem?
- Nie możemy napisać testów jednostkowych do metody calculateNewPrice ponieważ tworzone jest połączenie do bazy w metodzie.
- Nie możemy przekazać zmiennej $repository przez konstruktor ponieważ klasa jest wykorzystywana w X miejscach, a niektóre wywołania są niejawne i nie jesteśmy pewni wprowadzanej zmiany. Byłaby to zbyt „gruba” zmiana na tą chwilę.
- Metoda zawiera za dużo kodu legacy.
- Klasy wykorzystujące klasę Legacy również nie mają napisanych testów.
Jaki problem chcemy obecnie rozwiązać?
- Chcemy napisać testy do metody calculateNewPrice – bez modyfikacji konstruktora.
*W przyszłości zajmiemy się pozostałymi problemami tego kodu 😉
Jak możemy ten problem rozwiązać?
- Wydzielam tworzenie obiektu do osobnej metody getRepository o modyfikatorze dostępu protected.
class Legacy { public function __construct( private readonly PartyIdentifier $identifier ) { } public function calculateNewPrice(Money $currentPrice, \DateTime $date): Money { // dużoooo legacy $repository = $this->getRepository(); // jeszcze więcej legacy ... return $newPrice ?? $currentPrice; } protected function getRepository(): CustomerDiscountRepository { return new CustomerDiscountRepository(); } }
- W teście tworzę klasę anonimową rozszerzającą Legacy i nadpisuję metodę getRepository tak, aby zwracała mi mock’a klasy CustomerDiscountRepository. Przy takiej modyfikacji możemy już napisać testy do naszej metody zanim przejdziemy do dalszej refaktoryzacji naszej X linijkowej metody calculateNewPrice 🙂
use PHPUnit\Framework\TestCase; class LegacyTest extends TestCase { private CustomerDiscountRepository $repository; private Legacy $after; public function setUp(): void { $this->repository = $this->createMock(CustomerDiscountRepository::class); $this->after = new class ($this->repository) extends Legacy { private CustomerDiscountRepository $repository; public function __construct(CustomerDiscountRepository $repository) { parent::__construct(new PartyIdentifier('UUID')); $this->repository = $repository; } protected function getRepository(): CustomerDiscountRepository { return $this->repository; } }; } public function testCalculatePriceWhenNoDiscount(): void { // test } public function testCalculatePriceWhenTenPercentageDiscount(): void { // test } public function testCalculatePriceOnFridayWhenNoDiscount(): void { // test } public function testCalculatePriceOnFridayWithDiscount(): void { $this->repository ->expects($this->once()) ->method('get') ->willReturn(10); $originalPrice = Money::PLN(1000); $newPrice = Money::PLN(1080); $friday = new \DateTime('2022-07-22'); $this->assertEquals($newPrice, $this->after->calculateNewPrice($originalPrice, $friday)); } }
Rozszerzony kod tego przykładu możecie znaleźć u mnie na GitHub.
Znasz inny sposób na rozwiązanie tego konkretnego problemu? Napisz do mnie 😉