Phân biệt Mock và Stub

Khi viết các kiểm thử đơn vị cho phần mềm, chúng ta tập trung vào từng phần tử một trong phần mềm. Vấn đề là để một đơn vị phần mềm có thể hoạt động, nó thường cần tới các đơn vị khác. Một use case muốn hoạt động sẽ cần đến các giao diện vào ra như Controller hay Database chẳng hạn. Đó là lúc chúng ta sử dụng một giải pháp là “làm giả”. Bài viết này sẽ đi làm rõ giải pháp đó.

Bên cạnh đó, bài viết này sẽ chú trọng vào sự khác nhau giữa Mock và Stub. Bởi đằng sau chúng là sự khác biệt về hai cách để kiểm tra kết quả của một test case: state verification và behavior verification. Cũng như là một triết lý hoàn toàn khác về cách mà kiểm thử và thiết kế đi đôi với nhau khi thực hiện Phát triển Hướng Kiểm thử (TDD).

Một kiểm thử thông thường

Hãy xét một ví dụ đơn giản. Đặt trường hợp chúng ta đang đưa một object order (đơn đặt hàng) đi fill (nhận cung ứng) tại một warehouse (nhà kho). Order rất đơn giản với thông tin sản phẩm và số lượng. Warehouse là kho chứa của các sản phẩm khác nhau. Fill là hành động lấy . Khi đơn hàng yêu cầu nhận cung ứng sẽ có hai trường hợp xảy ra. Nếu có đủ hàng trong kho, đơn hàng sẽ được cung ứng và số lượng sản phẩm trong kho sẽ giảm theo. Nếu không có đủ hàng thì đơn hàng sẽ không được cung ứng và không có gì xảy ra với kho cả.

Dưới đây là một vài kiểm thử liên quan. Rất dễ hiểu, biểu cảm. Mã được viết bằng Java nhưng nguyên tắc sẽ không khác mấy với hầu hết các ngôn ngữ hướng đối tượng.

public class OrderStateTester extends TestCase {
  private static String TALISKER = "Talisker";
  private static String HIGHLAND_PARK = "Highland Park";
  private Warehouse warehouse = new WarehouseImpl();
​
  protected void setUp() throws Exception {
    warehouse.add(TALISKER, 50);
    warehouse.add(HIGHLAND_PARK, 25);
  }
​
  public void testOrderIsFilledIfEnoughInWarehouse() {
    Order order = new Order(TALISKER, 50);
    order.fill(warehouse);
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.getInventory(TALISKER));
  }
​
  public void testOrderDoesNotRemoveIfNotEnough() {
    Order order = new Order(TALISKER, 51);
    order.fill(warehouse);
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.getInventory(TALISKER));
  }

Framework kiểm thử ở đây (xUnit) đi theo bốn bước điển hình: setup (cài đặt), exercise (cho hoạt động/làm bài), verify (kiểm tra/chấm điểm), teardown (hủy bỏ). Trong ví dụ trên bước setup được thực hiện bởi phương thức setUp (đặt trạng thái cho warehouse) và một ít trong các kiểm thử (chuẩn bị order). Lời gọi tới order.fill là giai đoạn exercise, đây là lúc đối tượng được kích thích để thực hiện hành động mà chúng ta muốn kiểm thử. Các câu lệnh assert là giai đoạn verify, chúng ta kiểm tra xem các phương thức được exercise có làm việc đúng hay không. Ví dụ trên không có giai đoạn teardown. Hoặc có thể nói garbage collector đã làm việc đó một cách ngầm định.

Trong ngữ cảnh này thứ chúng ta thật sự muốn tập trung vào là Order. Những bước setup đã triển khai hai loại object. Order là class chúng ta muốn kiểm thử, nhưng chúng ta cũng cần đến một thực thể của Warehouse. Một mặt để Order.fill có thể hoạt động, một mặt khác để xác nhận ảnh hưởng của hành vi fill lên trạng thái của warehouse.

Chúng ta gọi Order là System Under Test (SUT), và gọi đối tượng warehouse là collaborator. Và cách kiểm thử trên đây được gọi là state verification (xác nhận theo trạng thái): chúng ta xác định xem phương thức exercise có hoạt động đúng hay không bằng cách khám phá trạng thái của SUT cùng với collaborator của nó sau khi phương thức thực thi xong.

Kiểm thử sử dụng các đối tượng Mock

Mã nguồn dưới đây sử dụng các đối tượng mock để kiểm thử. Ccas đối tượng Mock ở đây được tạo ra bằng thư viện jMock. Có nhiều thư viện khác có chung chức năng, nhưng jMock được viết bởi cha đẻ của kỹ thuật này nên sẽ rất tốt khi bắt đầu với nó.

public class OrderInteractionTester extends MockObjectTestCase {
  private static String TALISKER = "Talisker";
​
  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    Mock warehouseMock = new Mock(Warehouse.class);
    
    //setup - expectations
    warehouseMock.expects(once())
      .method("hasInventory")
      .with(eq(TALISKER),eq(50))
      .will(returnValue(true));
    warehouseMock.expects(once())
      .method("remove")
      .with(eq(TALISKER), eq(50))
      .after("hasInventory");
​
    //exercise
    order.fill((Warehouse) warehouseMock.proxy());
    
    //verify
    warehouseMock.verify();
    assertTrue(order.isFilled());
  }
​
  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    
    Mock warehouse = mock(Warehouse.class);
      
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));
​
    order.fill((Warehouse) warehouse.proxy());
​
    assertFalse(order.isFilled());
  }

Hãy cùng xem xét testFillingRemovesInventoryIfInStock trước. Chúng ta thấy một giai đoạn setup rất khác biệt. Nó được chia làm hai giai đoạn: định nghĩa các data và định nghĩa các expectation. Data là các object chúng ta sẽ làm việc cùng, tương tự như ở setup thông thường, nhưng có sự khác biệt ở đối tượng được tạo ra. Đối tượng SUT không thay đổi, nhưng collaborator không còn là một đối tượng của Warehouse nữa, thay vào đó nó là một đối tượng warehouse giả – về mặt kỹ thuật thì là một đối tượng của class Mock.

Expectations là các kỳ vọng về đối tượng mock. Chúng mô tả các phương thức phải được gọi tới mock khi SUT được cho hoạt động. Một khi tất cả các kỳ vọng đã được đặt ra thì SUT được cho exercise. Sau đó tại giai đoạn verify, một mặt thì trạng thái của SUT được xác nhận giống như phương pháp trước đó. Ở một mặt khác chúng ta kiểm tra xem các kỳ vọng đã đặt ra trên đối tượng mock có đạt tiêu chí hay không.

Khác biệt cốt lõi ở đây là ở cách chúng ta xác nhận xem đối tượng order đã thực hiện đúng tương tác cần thực hiện lên warehouse hay chưa. Với phương pháp state verification chúng ta làm điều đó bằng cách kiểm tra trạng thái của warehouse sau giai đoạn exercise. Còn cách làm ở đây sử dụng phương pháp behavior verification, theo đó chúng ta kiểm tra xem order có gọi đúng các phương thức mong muốn trên warehouse hay không. Chúng ta làm điều đó bằng cách chỉ cho đối tượng mock về các kỳ vọng trong giai đoạn setup, và sau đó hỏi verify đối tượng mock trong giai đoạn verify. Chỉ duy nhất order được kiểm tra bằng các assert, và nếu warehouse không hề thay đổi trạng thái thì order cũng hoàn toàn không quan tâm.

Kiểm thử testFillingDoesNotRemoveIfNotEnoughInStock có chút khác. Điểm đầu tiên là ở cách đối tượng mock được tạo. Thay vì constructor, chúng ta sử dụng phương thức mock của class MockObjectTestCase. Đây là một cách thuận tiện để không phải gọi phương thức verify một cách tường minh, nó sẽ được gọi một cách ngầm định sau mỗi test case. Kiểm thử đầu tiên đã không sử dụng cách này, với mục đích là để làm sõ các bước.

Điểm thứ hai là chúng ta thả lỏng kỳ vọng trên warehouse bằng bách sử dụng mô tả withAnyArguments. Lý do ở đây là kiểm thử đầu tiên đã kiểm thử về bộ tham số, chúng ta không lặp lại điều đó ở đây. Nếu logic của order cần có thay đổi thì chỉ duy nhất một kiểm thử sẽ fail, chúng ta sẽ mất ít công sức hơn để chỉnh sửa các kiểm thử. Chúng ta thậm chí có thể xóa mô tả withAnyArguments bởi nó là kỳ vọng mặc định.

Sử dụng EasyMock

Còn nhiều thư viện mock khác. EaseMock có cả phiên bản cho Java lẫn dotNET. Thư viện này cũng cung cấp khả năng behavior verification nhưng theo một cách đáng để thảo luận. Dưới đây là hai kiểm thử mà chúng ta đã quen thuộc.

public class OrderEasyTester extends TestCase {
  private static String TALISKER = "Talisker";
  
  private MockControl warehouseControl;
  private Warehouse warehouseMock;
  
  public void setUp() {
    warehouseControl = MockControl.createControl(Warehouse.class);
    warehouseMock = (Warehouse) warehouseControl.getMock();    
  }
​
  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    
    //setup - expectations
    warehouseMock.hasInventory(TALISKER, 50);
    warehouseControl.setReturnValue(true);
    warehouseMock.remove(TALISKER, 50);
    warehouseControl.replay();
​
    //exercise
    order.fill(warehouseMock);
    
    //verify
    warehouseControl.verify();
    assertTrue(order.isFilled());
  }
​
  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    
​
    warehouseMock.hasInventory(TALISKER, 51);
    warehouseControl.setReturnValue(false);
    warehouseControl.replay();
​
    order.fill((Warehouse) warehouseMock);
​
    assertFalse(order.isFilled());
    warehouseControl.verify();
  }
}

EasyMock sử dụng ẩn dụng về record/replay để cài đặt các kỳ vọng. Với mỗi đối tượng muốn làm giả chúng ta tạo ra một controler cùng với một đối tượng mock. Đối tượng mock đáp ứng giao diện của collaborator, trong khi đó controller cung cấp các tính năng bổ sung. Để chỉ ra một kỳ vọng chúng ta gọi phương thức mong muốn với đối số mong muốn. Tiếp theo chúng ta gọi controller để mô tả về kỳ vọng trả về. Chúng ta kết thúc việc cài đặt các kỳ vọng bằng cách gọi tới phương thức replay của controller — theo đó đối tượng mock sẽ kết thúc việc record và sẵn sàng để phản hồi với SUT. Tới cuối cùng chúng ta gọi tới verify của controller.

Lần đầu tiên nhìn thấy mã này có thể khiến ai đó đứng hình, nhưng một khi nắm được bản chất về record/replay, chúng ta sẽ rất nhanh chóng quen thuộc. Ưu điểm của phương pháp này là chúng ta tạo tương tác thật — gọi đến phương thức thực của đối tượng mock thay vì mô tả phương thức đó thông qua các string. Chúng ta có thể tận dụng code-completion của IDE để thực hiện bất kỳ tái cấu trúc đổi tên nào mà không sợ làm hỏng kiểm thử.

Sự đánh đổi ở đây là chúng ta không thể thả lỏng bớt kỳ vọng. Nhưng dù gì đi nữa thì các nhà phát triển của jMock cũng đang phát triển một phiên bản mới để cho phép cúng ta tạo ra các lời gọi phương thức thật.

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

Hai ví dụ trên đã trình bày về hai phong cách kiểm thử, ở trường hợp đầu tiên chúng ta sử dụng đối tượng warehouse thật và ở ca thứ hai chúng ta sử dụng đối tượng mock — thứ tất nhiên không phải là một warehouse thật. Sử dụng mock là một cách để làm giả warehouse nhằm mục đích sử dụng trong kiểm thử, nhưng vẫn còn nhiều hình thức khác của các đối tượng “giả”.

Chúng ta gọi chung các đối tượng giả đó là Test Double (Thế Thân Kiểm Thử). Và các hình thức thường thấy và được công nhận rộng rãi của chúng bao gồm như sau:

  • Dummy — những object được truyền đi qua các hàm nhưng thực ra không hề được sử dụng. Chúng tồn tại chỉ để điền cho đầy đủ danh mục tham số.
  • Fake — những objects có implement thực, nhưng thường là bằng một cách mưu hèn kế bẩn nào đó, với mục đích là trông y như thật. Chúng tất nhiên không phù hợp để tiêm vào môi trường production. Chẳng hạn một InMemoryTestDataBase là một ví dụ điển hình.
  • Stubs — những object có phản hồi với những lời gọi hàm được tạo ra trong ca kiểm thử, và thường không phản hồi bất kỳ lời gọi nào khác.
  • Spies — những stub mà có thêm hành vi ghi lại thông tin gì đấy về những hoạt dộng của chúng. Chẳng hạn một spies của dịch vụ gửi mail có thêm hành vi ghi lại thông tin rằng bao nhiêu email đã được gửi.
  • Mocks — là thứ chúng ta đang nói đến trong bài viết này, những object được lập trình sẵn các kỳ vọng về lời gọi sử dụng mà chúng sẽ nhận được, cũng như cách chúng sẽ phản hồi lại.

Trong các hình thức thế thân trên, chỉ duy nhất các mock đáp ứng được behavior verification. Các thế thân khác có thẻ, và thường là , sử dụng state verification. Các mock thực sự thực hiện những hành vi giống với các thế thân khác trong giai đoạn exercise — làm cho SUT tin tưởng rằng nó đang làm việc với một collaborator thật. Nhưng các mock khác biệt ở giai đoạn setup và verification.

Để tìm hiểu thêm, chúng ta mở rộng ví dụ. Nhiều người chỉ sử dụng thế thân trong trường hợp đối tượng thật quá khó để sử dụng. Một ca thường gặp là khi chúng ta muốn gửi một email trong trường hợp order không thành công. Vấn đề là chúng ta không muốn thực sự gửi đi một email trong giai đoạn kiểm thử. Vậy nên chúng ta tạo ra một thế thân của hệ thống gửi mail — thứ mà chúng ta có thể thao túng.

Sự khác biệt giữa mock và stub bắt đầu từ đây. Một stub trông sẽ như sau:

public interface MailService {
  public void send (Message msg);
}
​
public class MailServiceStub implements MailService {
  private List<Message> messages = new ArrayList<Message>();
​
  public void send (Message msg) {
    messages.add(msg);
  }
​
  public int numberSent() {
    return messages.size();
  }
}

Và chúng ta có thể sử dụng state verification bằng stub đó:

class OrderStateTester...
​
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub();
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }

Đây chỉ là một kiểm thử đơn sơ — chúng ta chỉ kiểm thử rằng thư đã được gửi, không kiểm thử xem nó đã được gửi đúng người, đúng nội dung hay chưa… Nhưng kiểm thử này cho chúng ta cái nhìn.

Mã sử dụng mock trông sẽ khác một chút.

class OrderInteractionTester...
​
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class);
    Mock mailer = mock(MailService.class);
    order.setMailer((MailService) mailer.proxy());
​
    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));
​
    order.fill((Warehouse) warehouse.proxy());
  }
}

Trong cả hai trường hợp chúng ta đều sử dụng một thế thân thay vì mail service thật. Nhưng trong trường hợp sử dụng state verification với stub, chúng ta cần có thêm một vài phương thức trên stub để có thể thực hiện verify. Kết quả là chúng ta có một stub triển khai MailService nhưng với các phương thức bổ sung.

Các đối tượng mock luôn sử dụng behavior verification. Stub thì đi theo cả hai cách — chúng có thể thực hiện behavior verification bằng cách spy. Nhưng cách thực hiện chính xác sẽ khác nhau.

Phái TDD Cổ Điển và TDD Mô Phỏng

Chúng ta tiếp tục có sự lưỡng phân tiếp theo, giữa TDD trường phái cổ điển và TDD mô phỏng (mockist). Sự khác biệt căn bản nằm ở chỗ lúc nào thì chúng ta sử dụng một mock (hay các thế thân khác).

Trường phái TDD Cổ điển sử dụng các đối tượng thật chừng nào còn có thể và sử dụng thế thân nếu quá khó để sử dụng “đồ thật”. Vậy nên các nhà phát triển theo Classical TDD sử dụng một warehouse thật và một mail service thế thân. Loại thế thân nào hầu như không quá quan trọng.

Trường phái TDD Mô phỏng, trái lại, luôn sử dụng một mock cho bất kỳ hành vi nào họ quan tâm — trong trường hợp này là cả warehouse lẫn mail service.

Mặc dù một lượng lớn các mock framework được thiết kế với tư tưởng mô phỏng, nhưng nhiều nhà cổ điển vẫn tìm thấy sự hữu dụng ở chúng khi cần tạo ra các thế thân.

Một nhánh của trường phái mô phỏng là Behavior Driven Development — Phát Triển Hướng Hành Vi (BDD). Ban đầu, để giúp học sử dụng TDD như một công cụ thiết kế, người ta đổi tên các kiểm thử thành các hành vi để giúp suy nghĩ về điều gì mà object phải làm, vậy là BDD ra đời. BDD áp dụng giải pháp mô phỏng, nhưng có mở rộng thêm, cả ở cách nó đặt tên các thứ, lẫn khát khao riêng của nó trong việc tích hợp các phân tích thống kê vào kỹ thuật của nó. Nhưng bàn về việc đó vượt quá xa khỏi phạm vi của bài viết này.

Đôi khi chúng ta sẽ gặp ai đó gọi “classical” là trường phái “Detroit” và “mô phỏng” là trường phái “London”. Đó là bởi XP ban đầu được phát triển trong dự án C3 tại Detroit và trường phái mô phỏng được phát triển bởi những người ủng hộ XP từ rất sớm tại London. Bên cạnh đó, nhiều nhà phát triển không thích khái niệm “TDD Mô Phỏng”, hay bất kỳ khái niệm nào khác phân biệt sự khác nhau giữa hai trường phái, theo họ thì điều đó là không cần thiết.

Lựa chọn giữa các sự khác biệt

Chúng ta có hai hệ lưỡng cực: state verification/behavior verification, và Detroit TDD/London TDD. Trước tiên hãy bàn về lựa chọn lối verification.

Điều đầu tiên phải làm là cân nhắc ngữ cảnh hiện tại. Chúng ta đang làm việc với một collaborator dễ dùng hay khó dùng?

Nếu collaborator là dễ dùng thì lựa chọn rất dễ. Nhà Detroit sẽ dùng một đối tượng thật và nhà London sẽ dùng một mock.

Nếu collaborator là khó dùng thì không hề có sự lựa chọn. Nhà Detroit sẽ dùng cách dễ nhất để có một thế thân và nhà London vẫn sẽ dùng một mock.

Chúng ta có thể thấy đây không hề là một lựa chọn lớn. Khó khăn thật sự nằm ở lựa chọn về trường phái: Detroit hay London. Bản sắc của mỗi cách verification ảnh hưởng đến cách chúng ta ra lựa chọn, và việc đó tốn nhiều năng lượng của chúng ta.

Nhưng hãy thử nghĩ. Nếu bất ngờ bạn sa vào một thứ gì đó khó dụng state verification. Một thứ sẽ rất làm khó các nhà phát triển Detroit. Nếu bạn là một người theo trường phái London, vấn đề không tồn tại với bạn.

Lèo lái quá trình phát triển với TDD

Trên đây đã nhắc đến rằng bản sắc của mỗi cách verification ảnh hưởng đến cách chúng ta ra quyết định. Các quyết định được nhắc đến ở đây là các quyết định thiết kế — thứ tiến hóa dần theo các vòng lặp được lái bởi các kiểm thử.

Hiệu ứng mà các đối tượng mock mang đến đó là khái niệm need-driven development — phát triển hướng nhu cầu. Theo đó việc phát triển phần mềm được bắt đầu bởi một user story bằng cách viết ra một kiểm thử đầu tiên cho mặt ngoài của hệ thống, đặt một số thành phần giao diện thành SUT. Bằng cách nghĩ về các kỳ vọng cho các collaborator, chúng ta khám phá sự tương tác giữa SUT và các láng giềng của nó — một cách hiệu quả để thiết kế interface đầu ra cho SUT.

Một khi kiểm thử đầu tiên đã chạy, những kỳ vọng trên các mock cung cấp cho chúng ta một đặc tả về điều tiếp theo cần làm, cũng như một điểm bắt đầu cho các kiểm thử tiếp theo. Chúng ta chuyển mỗi kỳ vọng thành một kiểm thử cho collaborator và lặp lại quá trình đó, từng SUT một. Cách này được gọi bằng một cái tên rất diễn tả là tiếp cận từ ngoài vào trong (outside-in). Nó dùng tốt với những hệ thống phân lớp. Chúng ta bắt đầu bằng cách lập trình cho lớp UI bằng cách sử dụng các mock của các lớp sâu hơn. Sau đó tiếp tục lập trình các lớp bên dưới của hệ thống, dần dần từng lớp một. Cách tiếp cận này rất có cấu trúc và dễ dàng điều khiển, một thứ mà nhiều người tin rằng sẽ hữu dụng để hướng dẫn các lập trình viên mới về OO và TDD.

TDD trường phái cổ điển không có những đường lối như thế. Chúng ta có thể sử dụng lối tiếp cận gần tương tự, bằng các stub thay vì các mock. Mỗi khi cần một thứ gì đó từ collaborator chúng ta chỉ đơn giản hard-code chính xác phản hồi mà cần thiết cho SUT. Một khi kiểm thử đã xanh, chúng ta thay thế mã hard-code bằng những mã thích đáng hơn.

Nhưng đổi lại, TDD cổ điển có thể làm những việc khác. Một lối tiếp cận khác là từ giữa ra ngoài (middle-out). Với cách này, chúng ta nhận chức năng và ra quyết định xem điều gì cần phải xảy ra ở tầng domain để chức năng có thể hoạt động. Chúng ta phát triển các đối tượng domain cần thiết và sau đó dần phát triển ra ngoài tới các đối tượng UI. Theo cách này chúng ta có thể không bao giờ phải làm giả bất kỳ thứ gì. Nhiều người thích cách này bởi nó tập trung vào mô hình tại domain trước, giúp mã logic tại domain không rò rỉ ra ngoài và hiện diện tại UI.

Lưu ý là những người ở cả hai trường phái đều phát triển theo từng user story một. Có một trường phái khác có tư duy phát triển hệ thống theo từng tầng một, việc phát triển tầng này sẽ không bắt đầu chừng nào một tầng khác đã hoàn thiện. Cả trường phái cổ điển lẫn mô phỏng đều không như thế. Họ có nền tảng agile và có xu hướng lựa chọn phát triển theo vòng lặp. Kết quả là họ làm việc trên từng tính năng một thay vì theo từng tầng một.

Cài đặt, trang bị

Với TDD cổ điển, chúng ta không những phải tạo SUT mà còn tất cả các collaborator cần thiết. Những ví dụ trong bài viết này chỉ có một vài đối tượng, những kiểm thử trong thực tế có thể sẽ cần một lượng lớn các đối tượng thứ cấp. Thường thì các object đó được tạo ra và hủy đi theo mỗi ca kiểm thử. Ở đây chúng ta gọi các đối tượng đó là “trang bị”.

Với mock, chúng ta chỉ phải tạo SUT và làm giả cho nó những láng giềng trực tiếp. Điều này có thể tránh việc phải bỏ công xây dựng những bộ trang bị phức tạp (mặc dù nhiều khi chúng phức tạp là do thiếu kỹ thuật hoặc công cụ thích hợp).

Trong thực tế, các nhà phát triển cổ điển sẽ có xu hướng tái sử dụng nhiều hết mức có thể các trang bị. Cá dễ nhất là đặt các mã cài đặt trang bị vào phương thức setup. Còn các trang bị phức tạp hơn là những thứ cần được sử dụng bởi các kiểm thử khác nhau, trong trường hợp đó chúng ta có thể tạo ra các class chuyên trị tạo trang bị — gọi là class mẹ. Các class mẹ rất cần thiết trong các kiểm thử lớn, nhưng cũng rất tốn công sức để bảo trì và tiến hóa, nếu không cẩn thận thì sẽ phải đánh đổi về hiệu năng không ít.

Cuối cùng thì bất kỳ trường phải nào cũng cho rằng bên còn lại phải làm nhiều việc. Phái London cho rằng tạo ra các trang bị rất tốn công. Phái Detroit cho rằng họ có thể tái sử dụng, còn các mock thì ca kiểm thử nào cũng phải tạo lại.

Tính cô lập của kiểm thử

Nếu gặp phải một bug trên một hệ thống với kiểm thử, nó thường chỉ khiến một kiểm thử duy nhất fail, đó là kiểm thử mà có SUT chứa bug. Với cách tiếp cận cổ điển, tất cả các kiểm thủ mà có sử dụng đến đối tượng chứa lỗi đều sẽ fail.

Một điều khác là mức nổi hạt của các kiểm thử. Bởi các kiểm thử classic sử dụng các object thật, mỗi một ca kiểm thử thật ra sẽ kiểm thử một nhóm các object, thay vì chỉ một. Chúng ta gọi điều này là nhiễu hạt quá thô. Nếu nhóm đó quá lớn thì có thể quá trình tìm kiếm nguyên nhân gây lỗi thực sự sẽ gặp khó khăn. Các nhà phát triển lối mô phỏng thì ít gặp vấn đề này hơn, bởi đối tượng thật duy nhất họ có trong các kiểm thử là các SUT.

Điểm cốt yếu là các kiểm thử đơn vị của trường phái cổ điển không thực sự “đơn vị”, chúng có thể coi là các kiểm thử tích hợp kích thước nhỏ. Vậy nên một kiểm thử ở lớp này có thể phát hiện ra một lỗi bị bỏ qua ở kiểm thử của một lớp khác. Các nhà phát triển mô phỏng mất khả năng này. Bên cạnh đó họ cũng gặp rủi ro rằng các kỳ vọng có thể sai sót, dẫn đến kiểm thử xanh nhưng che phủ những lỗi cố hữu.

Cho dù bạn sử dụng cách tiếp cận nào, điều quan trọng là nó phải được kết hợp với những kiểm thử nghiệm thu có độ nhiễu hạt thô hơn bao phủ lên toàn hệ thống. Một lượng không nhỏ các dự án phần mềm thất bại do muộn màng trong việc sử dụng kiểm thử nghiệm thu.

Dấp dính giữa kiểm thử và implementation

Khi viết một kiểm thử mô phỏng, chúng ta kiểm thủ lời gọi ra bên ngoài của SUT để đảm bảo rằng nó nói chuyện đúng cách với đối tác của nó. Một kiểm thử cổ điển sẽ chỉ quan tâm đến trạng thái cuối cùng — không quan tâm đến kết quả đó tới bằng cách nào. Vậy nên kiểm thử mô phỏng bị dấp dính (coupling) nhiều hơn với triển khai thật của phương thức. Thay đổi thứ tự của các lời gọi tới collaborator, hay tái cấu trúc ở phương thức implement thường kéo theo kiểm thử mô phỏng bị fail.

Sự dấp dính này dẫn với một vài khía cạnh khác. Điều quan trọng nhất là ảnh hưởng tới quá trình TDD. Với kiểm thử mô phỏng, khi viết kiểm thử, chúng ta nghĩ về sự triển khai của hành vi — thực tế thì nhiều người coi đó là một ưu điểm. Với kiểm thử cổ điển, điều quan trọng nhất chúng ta nghĩ về là điều gì xảy ra khi nhìn từ bên ngoài và bỏ qua mọi cân nhắc về triển khai cho đến khi kiểm thử đã xanh.

Việc dấp dính giữa kiểm thử và implementation có thể trở nên tệ hơn do bản chất của các công cụ mock. Phần lớn chúng đều quy định rất cụ thể cách khớp các lời gọi phương thức hay bộ tham số, kể cả khi chúng không liên quan đến kiểm thử nào. Việc thả lỏng các kỳ vọng một cách chừng mực có thể hạn chế điều này.

Phong cách thiết kế

Một trong những khía cạnh thú vị nhất của các cách tiếp cận kiểm thử là cách mà chúng ảnh hưởng đến các quyết định thiết kế. Các kiểm thử mô phỏng hỗ trợ lối tiếp cận từ ngoài vào trong, trong khi đó các nhà phát triển ủng hộ cách phát triển từ domain ra ngoài sẽ ưu tiên kiểm thử cổ điển.

Ở cấp độ nhỏ hơn, các nhà phát triển mô phỏng có xu hướng tránh các phương thức trả về giá trị, và ưa thích các phương thức mà hoạt động dựa trên một đối tượng thu thập. Lấy ví dụ về một hành vi thu thập thông tin từ một nhóm object và tạo ra một báo cáo. Cách thông thường là một phương thức report mà gọi các phương thức trả về string từ các đối tượng khác nhau và tập hợp chúng lại trong một biến lưu tạm. Một mô phỏng sẽ thích truyền một StringBuffer vào các đối tượng khác nhau và để chúng lưu chứa các thông tin vào trong buffer — họ coi đối tượng string buffer như một tham số thu thập.

Các mô phỏng gia nói nhiều hơn về việc tránh “gãy tàu” — việc sử dụng chuỗi phương thức theo kiểu getThis().getThat().getTheOther(). Tránh chuỗi phương thức còn được gọi là Đề xuất Demeter. Mặc dù chuỗi phương thức là một mùi, nhưng đổi lại các object đứng giữa cồng kềnh được chuyển qua lại giữa các phương thức thực chất cũng là một mùi.

Một trong những nguyên tắc khó hiểu nhất của thiết kế OO là “Tell Don’t Ask (chỉ bảo chứ đừng đòi hỏi)”, thứ khuyến khích bạn yêu cầu một đối tượng làm gì đó hơn là bóc tách dữ liệu của đối tượng ra để làm điều gì đó. Các mô phỏng cho rằng sử dụng các đối tượng mock giúp thúc đẩy điều này và giúp tránh việc các getter hiện diện khắp nơi trong mã. Các nhà cổ điển thì nói rằng họ có rất nhiều cách để cũng làm được điều này.

Một vấn đề được các nhà cổ điển thừa nhận đó là họ sẽ có xu hướng tạo ra các phương thức truy vấn chỉ để phục vụ mục tiêu verify (mà không phục vụ một chức năng có thật nào). Việc sử dụng behavior verification sẽ tránh được vấn đề đó. Nhưng các nhà cổ điển cho rằng những phương thức như vậy không nhiều.

Phái mô phỏng ủng hộ việc phân tách interface và khẳng định rằng sử dụng lối tiếp cận này sẽ khuyến khích các interface được phân tách nhiều hơn, vì mỗi collaborator được mô phỏng riêng biệt. Trong ví dụ string buffer ở trên, một mô phỏng gia có thể sẽ tạo ra một vài interface có ý nghĩa trong bối cảnh, và implement chúng bằng một string buffer.

Điều quan trọng là sự khác biệt ở đây cũng chính la động lực chính của các nhà phát triển theo phái mô phỏng. Nguồn gốc của TDD là từ mong muốn có được những kiểm thử hồi quy tự động mạnh mẽ để hỗ trợ thiết kế tiến hóa. Trên con đường người ta thấy rằng viết kiểm thử trước có thể cải tiến đáng kể quy trình thiết kế. Phái mô phỏng có một ý tưởng mạnh mẽ về việc loại thiết kế như thế nào thì là thiết kế tốt và họ đã phát triển các thư viện mock chủ yếu để hỗ trợ phát triển theo thiết kế này.

Vậy thì nên theo phái nào?

Bài viết này đã phân tích khá nhiều khía cạnh về ưu nhược điểm của từng trường phái. Chúng ta đã thấy rằng chúng thật ra có chung một câu hỏi tại sao, chung một động lực, dẫn đến cùng một đích. Mặc dù có sự phân biệt ưu nhược điểm này kia nhưng những sự phân biệt đó đều có tính chất bối cảnh. Điều quan trọng là rất khó để cảm thụ và đánh giá một kỹ thuật mà chưa thử nó một cách nghiêm túc. Nên nếu bạn muốn đâm đầu vào bất kỳ phái nào hơn thì cứ đâm thôi.


Refs:

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *