Test Doubles và DI¶
Khái niệm¶
Một trong những yêu cầu cơ bản của Unit Test đó là tính cô lập (isolation). Nhìn chung thì tính cô lập là rất khó (nếu không muốn nói là không thể) bởi luôn luôn có rất nhiều dependencies trong cả project.
Vì thế, khái niệm về Test Doubles
ra đời. Một Test Double
cho phép chúng ta loại bỏ dependency nguyên bản, từ đó giúp cô lập unit.
Dưới đây là một vài loại Test Doubles
Một vài phần trong các định nghĩa sau được lấy từ bài viết Mocks Aren't Stubs trên blog của Martin Fowler
Dummies¶
- Dummy là objects được truyền vào nhưng mà không hề được sử dụng. Chúng thường chỉ được dùng để hoàn thành danh sách parameter.
Fake¶
- Fake objects thực ra có mang những triển khai logic, thế nhưng thường sử dụng những "lối tắt", khiến chúng không thích hợp để triển khai trên production (Ví dụ như in memory database)
Stubs¶
- Stubs đưa ra những câu trả lời có sẵn cho các lời gọi hàm được thực hiện trong quá trình test, và thường sẽ không trả về bất cứ cái gì ngoài những thứ mà chúng đã được lập trình trong bài test.
Mocks¶
- Mocks là objects đã được lập trình trước với các expectations, tạo ra một đặc tả cho lời gọi mà chúng dự kiến sẽ nhận được.
Test cô lập¶
Test cô lập:
- Giả lập access API / web service ngoài (mạng)
- Giả lập access database?
- Giả lập lời gọi hàm từ các class khác?
Giả lập là gì?
- Giả lập = thay thế các object sử dụng cho production với 1 một object giúp cho việc testing
- Giả lập = thay thế real object với mock object
- Mock object bắt chước hành vi của real object, nhưng chúng ta có thể tự định nghĩa kết quả trả về theo từng kịch bản test case
Có hai quan điểm về unit test cô lập:
- Sociable test: Chỉ giả lập (mock) các dependencies gây chậm hoặc có side effects lớn hoặc không thể dùng trong test environment: database, network call...
- Solitary test: Giả lập tất cả dependencies
Ví dụ:
Có 3 class Order
, Customer
, Product
, với yêu cầu khi user order 1 sản phẩm, nếu ngày order trùng với ngày sinh của user thì user sẽ được giảm 20%.
Class Order
có 2 dependencies là Product
và Customer
, cần viết test cho method Order::price()
:
-
Sociable Test: do
Product::getPrice()
vàCustomer::getDiscount()
đều không có logic phức tạp hay có khả năng làm chậm test nên sẽ không thực hiện giả lậpnamespace Tests\Unit; use PHPUnit\Framework\TestCase; use App\Customer; use App\Product; use App\Order; use DateTimeImmutable; class SociableOrderTest extends TestCase { public function test_price_return_correct_value() { $product = new Product('PS4', 100); $customer = new Customer('Hunter', new DateTimeImmutable(date('Y/m/d'))); $order = new Order($customer, $product); $this->assertEquals(80, $order->price()); } }
-
Solitary Test:
class SolitaryOrderTest extends TestCase { public function test_price_return_correct_value() { // Giả lập (mock) Product $product = $this->createMock(Product::class); // Expect $product->getPrice() được gọi 1 lần và trả về 100 $product->expects($this->once()) ->method('getPrice') ->willReturn(100); // Giả lập (mock) Customer $customer = $this->createMock(Customer::class); // Expect $customer->getDiscount() được gọi 1 lần và trả về 20 $customer->expects($this->once()) ->method('getDiscount') ->willReturn(20); $order = new Order($customer, $product); $this->assertEquals(80, $order->price()); } }
Vì đã được giả lập nên khi logic của Product::getPrice()
và Customer::getDiscount()
thay đổi thì SolitaryOrderTest
không cần phải update lại.
Vậy khi nào cần giả lập (mock), chúng ta có thể áp dụng cả 2 quan điểm:
-
Nếu code được cấu trúc tốt thì thường có 2 loại class:
- Model: nhiệm vụ chính là để lưu dữ liệu bên trong và không thực hiện nhiều logic bên trong => không cần mock, chỉ đơn giản là khởi tạo đối tượng và truyền vào fake data cho nó
- Service: nhiệm vụ chính là thực hiện công việc, logic => mock
class OrderTest extends TestCase { public function test_price_return_correct_value() { $product = new Product('PS4', 100); // Giả lập (mock) Customer $customer = $this->createMock(Customer::class); // Expect $customer->getDiscount() được gọi 1 lần và trả về 20 $customer->expects($this->once()) ->method('getDiscount') ->willReturn(20); $order = new Order($customer, $product); $this->assertEquals(80, $order->price()); } }
-
Khi việc mock quá phức tạp hãy thực hiện refactor code hoặc thực hiện integration test
Dependency Injection¶
- Method
Order::price()
cần sử dụngProduct::getPrice()
vàCustomer::getDiscount()
nên classOrder
có 2 dependencies làProduct
vàCustomer
- Muốn giả lập thì các dependencies phải được khai báo tường minh (explicit dependencies), tức là được truyền vào constructor, class không có nhiệm vụ khởi tạo dependencies
- Nếu khởi tạo bằng
new
hoặc dùngstatic
method bên trong thì không có cách nào để giả lập (thực ra thì cũng có nhưng thường dùng trick về autoloading của PHP chứ PHP không hỗ trợ kỹ thuật Monkey Patching (*))
// Bad method
public function register(array $inputs)
{
// ...
(new MailService())->send($user);
// …
MailService::send($user);
}
// Good, using dependency injection
public function __construct(MailService $mailService)
{
$this->mailService = $mailService;
}
public function register(array $inputs)
{
$transaction = $this->mailService->send($user);
}
Mocks vs Stubs¶
Có nhiều loại test doubles, nhưng chỉ cần focus vào 2 loại chính
Stubs¶
Giả lập trạng thái => loại bỏ tất cả logic bên trong method của object thật và có thể thay đổi kết quả trả về của method theo ý muốn
// Create mock object
$product = $this->createMock(Product::class);
// Stub method price, make it return 100
$product->method('getPrice')->will($this->returnValue(100));
Mocks¶
Verify hành vi => khi method under test được gọi thì chúng ta expect mock method cũng được gọi, 1 lần hoặc 2 lần hoặc nhiều lần, được truyền tham số gì...
public function test_order_sends_mail_if_succeeded()
{
$mailService = $this->createMock(MailService::class);
$order = new Order('Wine', 'user@localhost', $mailService);
// Expect method MailService::send được gọi duy nhất 1 lần,
// với 2 tham số là 'user@localhost' và 'Order succeeded!'
// Expect cần được viết trước khi gọi method test
$mailService->expects(once())
->method('send');
->with(
$this->equalTo('user@localhost'),
$this->equalTo('Order succeeded!')
);
$result = $order->process();
$this->assertTrue($result);
}
Tham khảo¶
(*)
- https://github.com/Codeception/AspectMock
- https://github.com/kahlan/kahlan
- https://github.com/infection/infection
- https://www.phpspec.net/en/stable/