A Stockphoto - stock.adobe.com
Use cases of various types of test doubles for unit testing
Test doubles run the gamut from mocks, to stubs, to fakes and spies. Let's examine the ups and downs of using various test double types with unit testing.
Test doubles can be useful for load testing, as they can stand in as functional substitutions for third-party services whose rate limits make testing at scale impossible. Test mocks are a less common workaround to third-party services' rate limits, as they are designed for testers to use with unit tests and other lower-level integration tests.
A better way to test a code base is to use test doubles jointly with unit tests, while limiting interactions with systems that the code depends on but that exist outside of the code base. Ideally, a unit test is just a test of a small unit of your code. A unit test should be exercising some method or function from your application in an isolated scenario.
Let's use a simple example to illustrate the different types of test doubles in unit tests.
The example
Say there's an application to store digital books. The application connects to a database to store and retrieve books for customers to read. A simplified version of a class BookStore within the application may take an object during instantiation called BookDBConnector. The object BookDBConnector handles the connection to the database using a built-in library the programming language provides. In order to test methods in BookStore, some value must stand in for BookDBConnector with the class BookStore. Here's where we get to use test doubles.
Test mocks and unit tests
Mocking is the easiest and most widely known type of test double. Using a mock in this scenario would look like this:
def mockTest():
mockDBConnector = mock(BookDBConnector)
bookStore = BookStore(mockDBConnector)
assertEquals(bookStore.booksCheckedOut, 0)
A third-party library, like Mockito or other mocking libraries, create a usable object of the type BookDBConnector, which then enables us to instantiate the class BookStore. Notice that, in this test, we verify that the number of books checked out is equal to zero. This data isn't a value that is stored in the database, but rather in the BookStore class itself during application runtime. In this scenario, the BookStore class doesn't need to use BookDBConnector for our test. However, the BookStore class does need BookDBConnector to be created. Mocking is a perfect solution here because we need only the bare minimum of a shell of the BookDBConnector object to get the code to run.
Mocks are quick and easy to use in this case, but there are some downfalls to using mocks. Sometimes, mocks can make your tests less meaningful if the mocked class doesn't represent the production class well. Another common issue with mocked classes is that they encourage white box testing. When a team mocks classes, the implementation behind the class or function being tested is defined by the tests, and the results from the mocked class are predetermined. This type of white box testing creates a dependency on the specific implementation of the functions the team is testing.
Ideally, tests should validate classes and functions as a black box, where the implementation isn't known. Consequently, there are no external dependencies that would create burdensome technical debt when testers update implementation details in the underlying functions under test.
Test stubs and unit tests
Test stubs take mocks a bit further by simply defining stubbed or hardcoded responses in a mocked class. For example, we could define a MockBookDBConnector like so:
class MockBookDBConnector:
def getListOfBooks():
return [("Moby Dick", "Herman Melville"), ("The Count of Monte Cristo", "Alexandre Dumas"), ("The Road", "Cormac McCarthy")]
Using this MockBookDBConnector class, we can then test methods in BookStore that would require some functionality from BookDBConnector. Stubs enable more in-depth testing than mocks since they are able to provide a bare minimum functionality over the mere placeholder of a mocked class.
Test fakes and unit tests
With a test fake, a stubbed class will have additional functionality for the purpose of replicating the production class even more closely. Adding onto our MockBookDBConnector class to turn it into a fake looks like this:
class FakeBookDBConnector:
books = [("Moby Dick", "Herman Melville"), ("The Count of Monte Cristo", "Alexandre Dumas"), ("The Road", "Cormac McCarthy")]
checkoutOutBooks = []
def getNumberOfBooks():
return len(books)
def getListOfBooks():
return books
def checkOutBook(bookTitle):
checkoutOutBooks.append(books.pop(bookTitle))
Our FakeBookDBConnector class now emulates a production BookDBConnector class even closer by providing the functionality to check out books. The MockBookDBConnector class simulates this functionality by updating an in-memory list of books and checked-out books. While fakes can be useful for more involved testing of classes and functions, it's important to consider that fake classes are a representation of production code that a team may need to update with major changes to the corresponding production classes.
Test spies and unit tests
Possibly the most complex type of test double, test spies are test objects that combine mocks, stubs, fakes and production classes. Testers create test spies from a production object but stub it to modify specific behaviors or methods, while leaving the rest of the production behavior untouched. Working off of our first example, a test spy of BookDBConnector looks like this:
def spyTest():
spyBookDBConnector = spy(BookDBConnector())
when(spyBookDBConnector.getNumberOfBooks()).thenReturn(5);
bookStore = BookStore(spyBookDBConnector)
assertEquals(bookStore.getNumberOfBooks(), 5)
Because test spies combine stubbed methods with production methods in a spied class, it may be best to avoid using spies if possible; mocking should be your first choice. Mixing mocks and production code can quickly lead to unintended consequences. It's much simpler to mock only the functionality necessary for testing than to duplicate an entire class with a spy.Notice that we created a spy object of BookDBConnector, just like we created a mock of BookDBConnector in the first example. Using the mocking library, we then modify the getNumberOfBooks() method to always return 5. Methods other than getNumberOfBooks() remain intact and untouched, referring to the production implementation of BookDBConnector when called. Spies are useful for simulating error cases, where you want to leave the rest of the class as is but force a method to raise an exception.
As a side note, it is also true that the term test spy can refer to a method that records some state about its usage. For example, a team might stub a method of a mocked class and then create a class variable to record how many times your stubbed method gets called. The idea here is that you've created a spy within the mocked class to report information about another method's behavior. Test spies, in this sense, can be useful for testing classes that may have unintended race conditions or indeterminate behavior.
As the scenarios above display, test doubles have many different applications, and their usage has advantages and disadvantages. Mocks are the easiest to use but can lead to meaningless tests that don't exercise functionality in a way that mimics a production application. Stubs give mocks more definition and get closer to a complete functioning class. Fakes implement actual functionality to create a mock that maintains a realistic facade of a working production class. Spies combine all the above to force a production object into a specific state, with stubbed methods. Start with the simplest option of a basic mock before diving into higher-order test doubles.