Unit Tests: Strategies for Naming, Structuring, and Setup

Nicolle Policiano
Policiano
Published in
3 min readApr 13, 2024
Photo by Joel Filipe on Unsplash

Writing effective unit tests in Swift involves some strategies that optimize clarity, maintainability, and execution speed. This article will explore approaches for naming, structuring, and setting up unit tests, using examples from a hypothetical application that fetches movie details.

Consider the following Swift code examples that demonstrate two unit tests for a MovieDetailsViewModel:

final class MovieDetailsViewModelTests: XCTestCase {
private func makeSUT(stubbing result: Result<MovieDetails, Error>) -> MovieDetailsViewModel {

let service = MovieDetailsServiceSpy()
service.expectedResult = result

let sut = MovieDetailsViewModel(service: service)

return sut
}

// Failure to fetch data
func testStateIsErrorWhenFetchingFails() {
// Given
let sut = makeSUT(stubbing: .failure(ErrorDummy()))

// When
sut.fetchMovieDetails()

// Then
XCTAssertEqual(sut.state, .error)
}

// Success in fetching data
func testStateIsContentWhenFetchingSucceeds() {
// Given
let expectedMovieDetails = MovieDetails(name: "Some Movie")
let sut = makeSUT(stubbing: .success(expectedMovieDetails))

// When
sut.fetchMovieDetails()

// Then
XCTAssertEqual(sut.state, .content(expectedMovieDetails))
}
}

Test Naming

Effective test naming is crucial for clarity and ease of maintenance. The names should be concise yet descriptive, outlining the test’s scope and expected result, similar to git commit messages. Adopting a consistent naming pattern is beneficial, especially in scenarios with multiple functions, to facilitate reviews.

In the example above, the first test ensures that an error state is triggered upon a fetch failure, and the second one ensures that a content state is set upon fetch success. Both tests have similar names, starting with the word test followed by a short description in the camel case. Let’s take this one:

testStateIsErrorWhenFetchingFails
  • “State is error …”: This is the outcome or the expected result.
  • “… when fetching …”: Fetching is the action we are testing.
  • “… fails.”: The precondition for the test is that it fails on fetch.

Naming becomes easier when the test is trivial. In the next section, we will see tips for achieving this triviality.

Test Structuring

Tests should be compact and run quickly, targeting a single functionality and good readability. The structuring of the test can follow known patterns such as Given/When/Then or Arrange/Act/Assert, which helps organize the test code into three logical parts. Both patterns are similar in their meaning, so:

  • Given/Arrange: Prepares the environment and test variables.
  • When/Act: Invokes the method under test.
  • Then/Assert: Checks the results against expected outcomes.

Back to the example, you will notice that I partitioned the test into three steps:

// Given
let sut = makeSUT(stubbing: .failure(ErrorDummy()))

// When
sut.getMovieDetails()

// Then
XCTAssertEqual(sut.state, .error)

Commenting on the sections is up to you. The simpler and more compact the tests are, the easier they are to read, so commenting can sometimes become too obvious.

Single Assertion

During my learning, I was tempted to throw a bunch of assertions into a single test to reuse the test setup. However, figuring out a name for the test becomes more challenging when we have many assertions. What exactly are you testing?

When possible, it is advisable to stick with a single assertion per test to enhance test clarity and focus. This approach facilitates easier diagnosis when tests fail and ensures that each test addresses a unique aspect of functionality according to the single responsibility principle. For tests requiring multiple validations, consider dividing them into separate tests.

// Then
XCTAssertEqual(sut.state, .content(expectedMovieDetails))

Test Setup Organization

Keeping the test setup organized and separate from the test body is important to avoid code duplication. For example, if several tests require similar configurations, this setup can be moved to a setUp method executed before each test. I personally prefer to create a factory method, which I name makeSUT. The factory method approach has a few more advantages, such as tracking for memory leaks and so on. I would like to write a dedicated post for it.

This not only reduces code duplication but also makes each test easier to read and maintain.

private func makeSUT(stubbing result: Result<MovieDetails, Error>) -> MovieDetailsViewModel {
let service = MovieDetailsServiceSpy()
service.expectedResult = result

let sut = MovieDetailsViewModel(service: service)

return sut
}

Conclusion

It has been challenging to learn and adopt unit tests in my workflow, and any convention to simplify it is very welcome.

In this post, I presented some conventions that I’ve seen around that really made a difference in my learning. Also, having a standardized and strict way to write tests is crucial when dealing with multiple projects or huge codebases.

I hope this article helped you somehow. See you next time 👋

--

--