Friday, March 18, 2011

Unit Testing: Mock không phải là Stub

Thuật ngữ 'Mock Object (Đối tượng giả định)' đã trở nên phổ biến, nó mô tả về các đối tượng trong trường hợp đặc biệt bắt chước các đối tượng thật trong kiểm thử. Hầu hết các ngôn ngữ lập trình bây giờ đều có framework giúp đơn giản hóa việc tạo các mock object. Mock object thường không thực tế, tuy nhiên là hình mẫu của đối tượng kiểm thử trong trường hợp đặc biệt và cho phép một cách khác của việc kiểm thử. Trong bài viết này, tôi sẽ giải thích các mock object làm việc như thế nào, bằng cách nào chúng giúp cho việc kiểm thử dựa trên xác nhận tình trạng thạng thái, và bằng cách nào sử dụng chúng để phát triển một cách khác của việc kiểm thử.

Phương pháp kiểm thử cổ điển

Tôi sẽ bắt đầu bằng việc minh họa hai phương pháp kiểm thử khác nhau của một ví dụ đơn giản. Chúng tôi muốn tạo một đối tượng đơn đặt hàng và thực hiện giao hàng cho đơn đặt hàng này từ một đối tượng kho hàng. Đơn hàng trong trường hợp rất đơn giản, chỉ có một sản phẩm và số lượng đặt hàng. Kho hàng giữ số lượng tồn kho của các sản phẩm. Khi chúng ta yêu cầu giao hàng cho một đơn hàng từ kho hàng, sẽ có hai trường hợp xảy ra. Nếu đủ số lượng trong kho hàng theo yêu cầu của đơn hàng thì tiến hành giao hàng, đơn hàng có trạng thái được giao hàng và số lượng tồn kho của sản phẩm tương ứng trong kho được giảm xuống đúng bằng số lượng đã giao. Nếu không đủ số lượng tồn kho theo yêu cầu của đơn hàng, đơn hàng sẽ không thể tiến hành giao hàng và kho hàng vẫn giữ nguyên trạng thái cũ.
Có hai trạng thái bao hàm trong hai trường hợp kiểm thử đối với đối tượng Order, sau đây là đoạn code kiểm thử được cài đặt bằng ngôn ngữ C# sử dụng NUnit.
[TestFixture]
public class OrderStateTester
{
    private static String TALISKER = "Talisker";
    private static String HIGHLAND_PARK = "Highland Park";

    private IWarehouse warehouse;

    [SetUp]
    protected void Setup()
    {
        warehouse = new WarehouseImpl();
        warehouse.Add(TALISKER, 50);
        warehouse.Add(HIGHLAND_PARK, 25);
    }

    [Test]
    public void TestOrderIsFilledIfEnoughInWarehouse()
    {
        Order order = new Order(TALISKER, 50);

        order.Fill(warehouse);

        Assert.IsTrue(order.IsFilled);

        Assert.IsTrue(0 == warehouse.GetInventory(TALISKER));
    }

    [Test]
    public void TestOrderDoesNotRemoveIfNotEnough() 
    {
        Order order = new Order(TALISKER, 51);
        
        order.Fill(warehouse);
    
        Assert.IsFalse(order.IsFilled);

        Assert.IsTrue(50 == warehouse.GetInventory(TALISKER));
    }
}
Unit test thông thường có bốn bước theo thứ tự: thiết lập (setup), thực thi (execute), xác minh (verify) và kết thúc (teardown). Trong ví dụ trên thì bước thiết lập đã được thực hiện trong phương thức Setup (thiết lập kho hàng) và một phần của trường hợp kiểm thử (thiết lập đơn hàng). Việc gọi order.Fill chính là bước thực thi. Đây chính là đối tượng bị tác động để thực hiện những gì mà ta muốn kiểm thử. Các câu lệnh Assert.IsTrue và Assert.IsFalse là bước xác minh trạng thái, kiểm tra xem kết quả của việc thực thi phương thức có chính xác không. Trong trường hợp này không cần bước kết thúc, bởi vì thì trình thu dọn rác (garbage collector) đã tự động làm việc này.
Trong bước thiết lập có hai loại đối tượng mà chúng ta thực hiện cùng nhau. Order là lớp mà chúng ta kiểm thử, nhưng để Order.Fill thực hiện được chúng ta cần một instance của Warehouse. Trong trường hợp này Order là đối tượng mà chúng ta tập trung vào để kiểm thử. Có nhiều thuật ngữ để ám chỉ đến đối tượng này, nhưng trong bài này ta sẽ sử dụng chung một thuật ngữ "System Under Test (SUT)".
Vì vậy, đối với việc kiểm thử này chúng ta cần SUT (Order) và một cộng tác viên (Warehouse). Chúng ta cần kho hàng cho hai lý do: một là để lấy trạng thái đã được kiểm thử trong hầu hết trường hợp (vì Order.Fill gọi các phương thức của kho hàng) và thứ hai là cần nó để xác nhận (vì một trong những kết quả của Order.Fill là một trường hợp thay đổi trạng thái của kho hàng).
Phương pháp kiểm thử này sử dụng cách xác nhận trạng thái (state verification): có nghĩa là chúng ta xác định phương thức đã làm việc một cách chính xác hay không bằng cách xem xét trạng thái của SUT và cộng tác viên của nó sau khi phương thức đó được thực hiện. Như bạn sẽ thấy, mock object đưa ra một cách tiếp cận khác để xác nhận.

Kiểm thử với đối tượng giả định (Mock Object)

Bây giờ chúng ta sẽ tạo một kịch bản tương tự bằng cách sử dụng mock object. Trong trường hợp này, tôi sẽ sử dụng thư viện Moq (http://code.google.com/p/moq/) để định nghĩa mock.
[TestFixture]
public class OrderInteractionTester
{
    private static String TALISKER = "Talisker";

    [Test]
    public void TestFillingRemovesInventoryIfInStock()
    {

        //thiết lập - dữ liệu
        Order order = new Order(TALISKER, 50);

        //thiế lập - kỳ vọng
        var warehouseMock = new Mock<IWarehouse>();

        warehouseMock
            .Setup(warehouse => warehouse.HasInventory(TALISKER, 50))
            .Returns(true);
        warehouseMock
            .Setup(warehouse => warehouse.Remove(TALISKER, 50));

        //thực thi
        order.Fill(warehouseMock.Object);

        //xác nhận
        warehouseMock.Verify();

        Assert.IsTrue(order.IsFilled);
    }

    [Test]
    public void TestFillingDoesNotRemoveIfNotEnoughInStock()
    {

        Order order = new Order(TALISKER, 51);

        var warehouseMock = new Mock<IWarehouse>();
        warehouseMock
            .Setup(warehouse => warehouse.HasInventory(TALISKER, 51))
            .Returns(false);

        order.Fill(warehouseMock.Object);

        warehouseMock.Verify();

        Assert.IsFalse(order.IsFilled);
    }
}
Chúng ta hãy tập trung vào phân tích trường hợp kiểm thử TestFillingRemovesInventoryIfInStock.
Việc thiết lập (setup) cho trường hợp kiểm thử được được chia thành hai phần: dữ liệu và sự kỳ vọng.
Phần dữ liệu thiết lập các đối tượng SUT mà chúng ta sẽ tập trung vào làm việc với nó, phần này tương tự như bước thiết lập ở trên. Tuy nhiên, cộng tác viên lúc này không phải là một đối tượng warehouse thật, mà được thay thế bằng một mock warehouse giả định - một instance của class Mock. Phần thứ hai của việc thiết lập là tạo các kỳ vọng của mock object. Các kỳ vọng chỉ ra các phương thức được gọi từ các mock khi SUT được thực thi và giá trị trả về kỳ vọng của phương thức đó (tức là xem như các phương thức của mock luôn thực hiện đúng).
Toàn bộ các kỳ vọng đều được thực thi trong SUT. Sau khi thực thi tôi tiến hành xác minh hai khía cạnh. Tôi chạy các xác nhận dựa trên SUT như ở ví dụ trước. Tuy nhiên, tôi cũng xác minh cả mock - kiểm tra xem chúng được gọi dựa trên các kỳ vọng của chúng.
Điểm khác biệt chính ở đây là bằng cách nào chúng ta xác minh được đơn hàng đã được thực hiện đúng khi tương tác với kho hàng. Chúng ta xác minh trạng thái bằng cách dựa vào trạng thái của kho hàng. Mock sử dụng xác nhận hành vi (behavior verification), thay vì kiểm tra để thấy rằng đơn hàng tạo các lời gọi đúng đến kho hàng. Chúng ta thực hiện việc kiểm tra này bằng cách nói với mock cái mà chúng ta kỳ vọng khi thiết lập và yêu cầu mock tự xác minh lại sau đó. Chỉ có đơn hàng là cần được kiểm tra bằng cách sử dụng các lệnh xác nhận, và nếu phương thức không thay đổi trạng thái của đơn hàng thì không có gì thay đổi cả.

Sự khác nhau giữ Mock và Stub

Khi bạn đang thực hiện kiểm thử, lúc đó bạn đang tập trung vào một phần tử của phần mềm do đó thuật ngữ chung là unit testing (kiểm thử đơn vị). Vấn đề là khi bạn tạo một đơn vị công việc riêng lẻ, bạn thường cần các đơn vị khác - như trong ví dụ trên cần đến kho hàng.
Trong hai phương pháp kiểm thử ở trên, trường hợp đầu tiên sử dụng một đối tượng kho hàng thật và trong trường hợp thứ hai sử dụng một kho hàng giả định (mock), mà tất nhiên không phải là một đối tượng kho hàng thực tế. Sử dụng mock là một cách để không phải sử dụng các kho hàng thực tế khi kiểm thử, trong thực tế có nhiều kiểu đối tượng phi thực tế khác được sử dụng trong phương pháp kiểm thử này.
Có nhiều từ ngữ để mô tả về kiểu đối tượng này: stub, mock, fake, dummy. Trong bài này sẽ sử dụng từ vựng theo sách của Gerard Meszaros. Meszaros sử dụng thuật ngữ Test Double (Kiểm thử đôi) như một thuật ngữ chung cho bất kỳ loại đối tượng phi thực tế nào thay thế cho đối tượng thật để phục vụ cho mục đích kiểm thử. Meszaros định nghĩa bốn từ ngữ cụ thể cho thuật ngữ này:
  • Dummy là các đối tượng được di chuyển khắp nơi nhưng không bao được sử dụng thật sự. Thường chúng chỉ được sử dụng để truyền danh sách tham số.
  • Fake là các đối tượng thường được cài đặt một cách đầy đủ, nhưng thường tạo một số rút gọn dẫn đến chúng không phù hợp cho môi trường thật (một database in memory là một ví dụ tốt).
  • Stubs cung cấp hộp câu trả lời cho các cuộc gọi trong khi kiểm thử, thông thường không đáp lại bất kỳ thứ gì ngoài những gì đã được lập trình cho kiểm thử. Stubs cũng có thể ghi lại thông tin về các lệnh gọi, như một email gateway stub ghi nhớ các thông điệp nó "đã gửi", hoặc có lẽ chỉ là có bao nhiêu thông điệp nó 'đã gửi'.
  • Mocks là cái mà ta đang nói đến: là các đối tượng được lập trình trước với kỳ vọng tạo thành một đặc tả màcác lệnh gọi được mong chờ sẽ nhận được.
Trong các loại trên, chỉ có mock khẳng định tính xác nhận hành vi (behavior). Với những cái còn lại, thường thực hiện sử dụng xác nhận trạng thái. Mocks thường tương tự như các loại khác trong pha thực thi, chúng cần tạo cho SUT tin rằng nó đang nói chuyện với các cộng tác viên thật - nhưng mock khác trong các bước thiết lập và xác nhận.
Để minh họa cho vấn đề này, tôi sẽ tạo một ví dụ. Nhiều người chỉ sử dụng kiểm thử đôi nếu khó có thể làm việc được với đối tượng thực tế. Ví dụ đơn giản cho kiểm thử đôi là chúng ta sẽ gửi một email message nếu gặp thất bại khi tiến hành giao hàng cho đơn đặt hàng. Vấn đề là ta không muốn gửi các email thật tới khách hàng trong khi kiểm thử. Do đó ta tạo một kiểm thử đôi cho hệ thống email, ta có thể điều khiển và thao tác với nó.
Giờ ta có thể bắt đầu xem xét sự khác nhau giữa mock và stub. Nếu ta viết một kiểm thử cho hành vi của việ gửi mail, có lẽ đó chính là một stud đơn giản.
public interface IMailService
{
    void Send(Message msg);

}
public class MailServiceStub : IMailService
{
    private IList<Message> messages = new List<Message>();

    public void Send(Message msg)
    {
        messages.Add(msg);
    }

    public int NumberSent()
    {
        return messages.Count;
    }
}
Chúng ta có thể sử dụng stub để xác minh trạng thái như sau.
[TestFixture]
public class OrderStateTester
{
    ...
    
    [Test]
    public void TestOrderSendsMailIfUnfilled()
    {
        Order order = new Order(TALISKER, 51);
        
        MailServiceStub mailer = new MailServiceStub();
        
        order.SetMailer(mailer);
        order.Fill(warehouse);

        Assert.IsTrue(1 == mailer.NumberSent());
    }
}
Tất nhiên đây là một kiểm thử rất đơn giản – chỉ là việc gửi đi một message. Ở đây chúng ta cũng không thực hiện kiểm thử email được gửi đến đúng đối tượng không hoặc đúng nội dung gửi có đúng không, nhưng tôi nghĩ rằng trường hợp này cũng đủ để hình dung vấn đề.
Sử dụng mock để kiểm thử trong trường hợp này khá khác biệt.
[TestFixture]
public class OrderInteractionTester
{
    ...
    
    [Test]
    public void TestOrderSendsMailIfUnfilled() {
        Order order = new Order(TALISKER, 51);

        var warehouseMock = new Mock<IWarehouse>();
        var mailServiceMock = new Mock<IMailService>();


        mailServiceMock
            .Setup(mailService => mailService.Send(new Message()));

        warehouseMock
            .Setup(warehouse => warehouse.HasInventory(It.IsAny<string>(), It.IsAny<int>()))
            .Returns(false);

        order.SetMailer(mailServiceMock.Object);

        order.Fill(warehouseMock.Object);
    }
}
Trong cả hai trường hợp tôi sử dụng kiểm thử đôi thay vì sử dụng một dịch vụ mail thực tế. Có một điểm khác đó là stub sử dụng xác nhận trạng thái còn mock sử dụng xác nhận hành vi.
Để sử dụng xác nhận trạng thái trong stub, ta cần tạo một số phương thức mở rộng trong stub để giúp cho việc xác nhận. Do đó, stub cài đặt IMailService nhưng phải thêm một phương thức kiểm thử mở rộng là NumberSent.

Kết luận

Hy vọng rằng qua nội dung mô tả ở trên, bạn có thể phần nào hình dung được việc sử dụng Mock trong kiểm thử bằng Unit Test, cũng như phân biệt sự khác nhau giữa Mock và Stub.
Đây là bài được dịch sơ lược và diễn giải lại theo ý hiểu của tôi, bạn có thể thao khảo bài gốc bằng tiếng Anh từ địa chỉ Mocks Aren't Stubs. Trong bài tiếp theo, tôi sẽ cố gắng giải thích với bạn về cách sử dụng Moq trong Unit Testing và TDD.

2 comments:

  1. Anh cho xin tài liệu về những thứ này đi

    ReplyDelete
  2. @haison8x: Mình không có tài liệu nào cụ thể đâu, đây chỉ là bài viết theo ý hiểu cá nhân để tham khảo thôi.

    ReplyDelete