The Unit Testing Diet Part II: DRY code with Test Factories

Setting up test state in one line

--

Test Factories are like olive oil. A healthy ingredient that will help you cook your food — or set up your tests. Photo by Nikcoa on iStock.

In Part I of the Unit Testing Diet we presented how Behavior-Driven Development allows you to test and refactor an MVVM or MVI app at scale.

In BDD, test suites are written in a Given / When / Then format, known as Gherkin:

  • Given -> The current state
  • When -> The behavior (input)
  • Then -> The outcome (output)

The “When” is normally a user intent simulated by calling a function in the ViewModel, e.g. viewModel.onButtonTapped() and the “Then” is our assertion about the state, e.g. viewModel.state shouldBeEqualTo expectedState.

The question that we haven’t answered yet, is how do you simulate the “Given”? Mocking at every layer is one approach but they impede refactoring. Let’s explore a different approach: Test Factories.

Simulating the “Given” in our chat application

Back into our chat application example, let’s say that you receive a new requirement from the product team which doesn’t allow users that appear offline to send a chat message. The scenario in Gherkin would be the following:

Scenario: Offline user cannot send chat messages
Given I am offline
When I send a valid text message
Then It is not displayed

Let’s see how our code will be modified to handle this scenario:

class GetAccountStatusUseCase(
private val accountRepository: AccountRepository
) {

operator fun invoke(): Account.Status {
return accountRepository.account.status
}
}
class SendTextMessageUseCase(
private val getAccountStatusUseCase: GetAccountStatusUseCase,
private val chatMessagesRepository: ChatMessagesRepository
) {

operator fun invoke(text: String, user: User): Completable {
if (getAccountStatusUseCase() == Account.Status.Offline) {
return Completable.error(AccountOffline())
}

return if (isValid(text)) {
val message = MessageFactory.fromText(text)
chatMessagesRepository.sendChatMessage(message, user)
} else {
Completable.error(InvalidMessage())
}
}

private fun isValid(text: String): Boolean {
return text.length < 180
}
}

We’re introducing a GetAccountStatusUseCase that receives our Account from the AccountRepository and it exposes the Account.Status. The SendTextMessageUseCase is using it to check if we are offline. We could also inject the AccountRepository directly in the SendTextMessageUseCase instead of creating a separate UseCase. It doesn’t really matter, it’s an implementation detail that we can refactor once our tests are green (Red-Green-Refactor).

Now let’s figure out how this new scenario will be covered in the BDD test suite of the ChatViewModel. What’s interesting with this requirement is that it cannot be simulated in the chat screen. The option for users to go offline is in settings, a different screen, and it is not part of the ChatViewModel. Thus, we cannot do beforeEach { chatViewModel.onGoOfflineTapped() }.

And that’s where Test Factories come into play.

What are Test Factories?

They are classes that live in the test directories, unreachable by production code, that have the responsibility of simulating a state when that state is not bounded within the module/feature that we’re testing. They will simulate the “Given” of a scenario, if that “Given” is not controlled by the feature we are testing right now.

In our example, we’ll create an AccountTestFactory that has the responsibility of setting up the state for the user’s account, by storing it in the fake AccountDataSource:

class AccountTestFactory(
private val id: Long = 1,
private val name: String = "Stelios",
private val status: Account.Status = Account.Status.Online
) : KoinTest {

fun produce(): Account {
return Account(id = id, name = name, status = status)
}

fun save(): Account {
return produce().also {
get<AccountDataSource>().saveAccount(it)
}
}
}

The produce() function will generate a new Account while the save() function will store that Account in the fake AccountDataSource. The properties that we want to set as the current state of the Account can be passed in the constructor.

Whenever our code requests the Account from the AccountRepository, the repository will return the one that we’ve set in the fake data source. This gives us the flexibility of setting up common states for our Account that we can reuse across our test suites.

The AccountTestFactory can be used now in the BDD test suite of the ChatViewModel to set our “Given” state and evaluate the new behavior that we introduced. We’ll override the status by setting it to Account.Status.Offline:

Given("I am offline") {
AccountTestFactory(status = Account.Status.Offline).save()

When("I send a valid text message") {
viewModel.onTextMessageSent("2nd message")

Then("It is not displayed") {
stateObserver.lastValue() shouldBeEqualTo ChatViewModel.State(
messages = listOf(MessageUiModel("1st message", Message.Type.Received))
)
}
}
}

Note: A Test Factory can also be used to simulate the “When” of a scenario, if that behavior is an OS event and not something controlled by the user. For example, when the device emits a new location.

What’s the difference between Test Factories and mocks?

Test Factories will store the generated data in the fake data sources, however our BDD tests under the hood will still run the real ViewModel, UseCases and Repositories. None of these dependencies will be mocked. The production code that we want to evaluate in our tests will still run. As we mentioned in Part I of the Unit Testing Diet, the only layer that we mock is the outermost layer of Clean Architecture (databases, network calls, and third-party services that we do not control).

Why the term “factory”?

The term is borrowed from RSpec and FactoryBot and it reflects an abstraction that allows us to generate an object or a state, similarly to the factory method pattern.

Though most commonly associated with server-side Ruby tests, we feel the factory pattern brings similar benefits when applied to mobile tests.

Flex your Test Factories with Traits

Test Factories can become very powerful with traits. A trait in a Test Factory is a group of attributes that can produce an object with semantic meaning. For example the “Paid” account trait represents an account that has an active subscription. It can be simulated by calling AccountTestFactory().withPaid().save(), hiding from the caller the attributes required to set up a paid account:

class AccountTestFactory(
private val id: Long = 1,
private val name: String = "Stelios",
private val status: Account.Status = Account.Status.Online
) : KoinTest {

private var subscription: Subscription? = null

fun withPaid() = apply {
subscription = Subscription(
id = 1, storeType = StoreType.GooglePlay, expiryDate = Date().plusDays(1)
)
}

fun produce(): Account {
return Account(id = id, name = name, status = status, subscription = subscription)
}

fun save(): Account {
return produce().also {
get<AccountDataSource>().saveAccount(it)
}
}
}

The return type of the withPaid() function is the Test Factory itself, so that you can combine multiple traits using the builder pattern. For example, if we had a “Verified” trait we could call AccountTestFactory().withVerified().withPaid().save() to set up a verified account that has an active subscription.

Best practices of Test Factories

  • Each Test Factory should have default values in the constructor parameters, such that they will produce a minimum valid factory. For example the AccountTestFactory().save() should store a minimum valid account, one that has a valid id, name, and email address.
  • You should create Test Factories for any kind of common setup that falls into the outermost layer of Clean Architecture, such as setting data in a fake database, setting the response in a fake remote data source, or simulating an event coming from the OS, for example emitting a location from a fake location service. This will allow you to reuse that code across your test suites.
  • Use Test Factories for things that you cannot set as part of the feature you are testing. For example in our chat application, if you’re testing the settings screen and there is a button to go offline, you should test that behavior by calling settingsViewModel.onGoOfflineTapped() and not by using the Test Factory. However that behavior does not exist in chat, that’s why you’ll need to use the AccountTestFactory there to set up that state.
  • It may be a good practice to have a produce() and save() function in each factory. Sometimes you might want to produce an object without storing it, for example by using UserTestFactory().produce() to generate a user to chat with for the purpose of your tests. Both functions should return the generated object in case you need to access any of its property.
  • Lastly, Test Factories can be combined with fakers to randomize the values of the properties that we set. For example instead of hardcoding the name of the Account in the constructor of the AccountTestFactory we could do name = faker.name() to generate a random one each time. This can help you detect flaky tests.

Benefits of Test Factories

  • The biggest one is that you keep your code DRY (Don’t Repeat Yourself). As you’re building your BDD test suites, you’ll realize that you’ll have scenarios that will require a common state. Instead of repeating the same code all over your test suites, you’ll encapsulate that code in a single class.
  • As we described earlier, Test Factories are used to send data to the fake implementations of the outermost layer of Clean Architecture. If you encapsulate those interactions with the fake in a single class, it means that you can refactor that layer without updating your tests. The only thing that you’ll have to update is the respective Test Factory.
  • They give you a pattern on how to send data to fakes in your tests, rather than having each engineer doing it different. This makes the code more consistent and the tests easier to write.
  • Any complex setup required to set the desired state will be hidden from the tests, making them more readable and easier to maintain.
  • Traits allow you to easily identify and test edge cases that you may have missed.

Summary

In this Unit Testing Diet series, we presented a guide for Behavior-Driven Development in an MVVM or MVI architecture, by testing the behavior on the ViewModel layer and not the implementation details. We explained why mocking at every layer impedes the ability to refactor, and we introduced Test Factories, an essential tool to set up common state while keeping your code DRY.

From our experience at Perry Street Software, these practices have allowed us to unit test our apps at scale, without slowing down continuous refactoring.

More in this series

About the author

Stelios Frantzeskakis is a Staff Engineer at Perry Street Software, publisher of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 30M members worldwide.

--

--