Quality Unit testing: Part 2
Choose your button from the London school or Classical school of Unit testing.
This is part II of a Quality Unit testing series. In the first article, we looked at unit testing from a high level. If you haven’t read the article, please do so first: https://hellosagar.hashnode.dev/quality-unit-testing-part-1.
To recap a bit from Part 1
Unit testing means
1. Testing a piece of code.
2. Blazingly fast to run .relatively to Integration and E2E tests.
3. In an isolated manner.
You can't argue on the first two points, but the third one is where the debate is started.
Here, the isolation can vary depending on the approach you are following. There are mainly two schools of thought are:
- Classical school of unit testing or Detroit or Chicago Style.
- London school of unit testing or Mockist.
So, as you saw above the disagreement of the 3rd point from which these two approaches emerged.
Exposition (Plotting Context)
We have to write a unit test for the class Classroom
which holds the classroom behavior of a school. This adds the user to the database who has a valid school email address.
class Classroom(
private val admin: Admin
) {
fun add(email: String, type: Type): Boolean {
return if (email.endsWith("@school.com")){
admin.add(email = email, type = type)
}else {
false
}
}
}
Admin
class is responsible for managing the database of the school.
class Admin {
private val people: MutableMap<String, Type> = mutableMapOf()
fun get(email: String): Boolean {
return people[email] != null
}
fun add(email: String, type: Type): Boolean {
return if (people[email] == null) {
people[email] = type
true
} else {
false
}
}
}
Before moving forward to dig deep into each approach let's understand a few key terms before that:
SUT or System under test: Refers to the class that is being tested.
MUT or Method under test: Refers to the method in the SUT being tested. SUT and MUT are often used synonymously.
Dependency: Object that the SUT needs to function correctly.
Test double: Simpler version of a dependency to facilitate easy testing.
Categorization of dependencies:
There are two types of dependencies:
Shared dependency: Dependency which is shared among different tests and changes in its state is visible across all tests. This is usually a class-level variable or a static variable from a different class. Examples are Databases and file systems.
Private dependency: This is not shared among different tests and its a usually a method-level variable. It is of further two types:-
Mutable dependency: The state of an object is modified during the execution of the unit test.
Value Object: Immutable object that represents a simple value, such as a number or a string, and does not have any associated identity or behavior.
London school of unit testing or Mockist approach
This approach is also known as “White box testing” or “Outside-In” approach because in TDD when implementation is not defined already then the developer can start defining behavior for tests by replacing the dependency with mocks i.e test double and then drilling down from the top to bottom by defining unit tests without caring about the specifics of the class implementation.
Visual representation of London school of unit testing
Characteristics:
Unit: Tests only a single class at a time.
Isolation: Replace all the dependencies with a test double, to let the SUT solely focuses on the single class i.e SUT.
Dependency: Uses test double for all dependencies except immutable dependencies.
Assert: It tests the interaction to verify the expected behavior.
Here’s what the London school approach will look like:
My language of choice is Kotlin and using mockk to mock dependencies.
internal class ClassroomLondonStyleTest{
@Test
fun `WHEN email domain name is invalid, THEN returns false`(){
// Arrange
val admin = mockkClass(Admin::class)
val classroom = Classroom(admin)
// Act
val output = classroom.add("hellosagar@gmail.com", Type.STAFF)
// Assert
Assert.assertFalse(output)
verify(exactly = 0) { admin.add(any(), any()) }
}
@Test
fun `WHEN email already exist, THEN returns false`(){
// Arrange
val email = "hellosagar@school.com"
val admin = mockkClass(Admin::class)
every { admin.add(email, Type.STUDENT) } returns false
val classroom = Classroom(admin)
// Act
val output = classroom.add(email, Type.STUDENT)
// Assert
Assert.assertFalse(output)
verify(exactly = 1) { admin.add("hellosagar@school.com", Type.STUDENT) }
}
@Test
fun `WHEN email is entered first time, THEN returns true`(){
// Arrange
val email = "hellosagar@school.com"
val admin = mockkClass(Admin::class)
every { admin.add(email, Type.STUDENT) } returns true
val classroom = Classroom(admin)
// Act
val output = classroom.add(email, Type.STUDENT)
// Assert
Assert.assertTrue(output)
verify(exactly = 1) { admin.add("hellosagar@school.com", Type.STUDENT) }
}
}
Code breakdown
If you noticed that I’ve breakdown the test into 3 phases using AAA for organizing the tests this was introduced in the Martin Fowler book Refactoring: Improving the Design of Existing Code.
Arrange: Setup the necessary preconditions and inputs, which might involve creating objects, and setting up mocks.
Act: Invoke the MUT with the necessary inputs.
Assert: Verify that the tests produced the expected result.
Let’s take the third test case (WHEN email is entered first time, THEN returns true
) for the explanation purpose.
Starting with the Arrange phase:
On line 1 of the second test case, we just define the email needed to pass as the input to the MUT.
On line 2, instead of an actual instance of
Admin
class, we are using a mock which acts as a private mutable dependency of theClassroom
class.In line 3, we are mentioning that when
add()
the method ofAdmin
a class is invoked with the given email andSTUDENT
type then we want to want to returntrue
.On line 4, we are setting up the SUT with the necessary input i.e
Admin
class.
Coming to the Act phase:
Invoking the MUT i.e add()
method by passing inputs email
and type
which is both value objects and save the MUT’s output for the assert phase.
Then Assert phase:
- On line 1, we are asserting the output returned by the MUT and verifying that interaction as well which is if the email ends with the
@school
the email then we know thatAdmin
classadd()
the method must be invoked and that's what we verified as well.
Advantages
It removes the guesswork of what part of the codebase is broken that causes the test to fail as it focuses solely on the SUT behavior by removing all the external influences.
No deal with the circular dependencies and big object graph like the Russian nesting doll set :)
Drawbacks
- The biggest disadvantage is the over-specification, which couples the tests to the implementation details this happens because mocks are powerful and with great power comes great responsibility.
Note: If one has to use a lot of mocks then it’s clearly a design problem and mocking are only hiding that problem.
Classical school of unit testing or Detroit or Chicago Style approach
This approach is also known as “Black box testing” or “Inside-Out” approach because when following TDD here we usually use an actual instance of objects and don't mock anything unless it's a shared dependency. So it starts from a low-level unit of code and works on its way to the top.
Visual representation of Classical school of unit testing
Characteristics:
Unit: Consider a unit as a unit of behavior needed to test as if it takes more than one class, but it is always a good idea to keep one class per test.
Isolation: It means running tests either in parallel, in sequence, or in any order but that shouldn’t affect the output of each other.
Dependency: Uses test double only for the shared dependencies like database or file system rest is production ready object instances.
Assert: It verifies the return value or change in the state of the class.
Here’s what the Classical school approach will look like:
internal class ClassroomChicagoStyleTest{
@Test
fun `WHEN email domain name is invalid, THEN returns false`(){
// Arrange
val admin = Admin()
val classroom = Classroom(admin)
// Act
val output = classroom.add("sagarkhurana00786@gmail.com", Type.STAFF)
// Assert
Assert.assertFalse(output)
}
@Test
fun `WHEN email already exist, THEN returns false`(){
// Arrange
val email = "sagarkhurana00786@school.com"
val admin = Admin()
admin.add(email, Type.STUDENT)
val classroom = Classroom(admin)
// Act
val output = classroom.add(email, Type.STUDENT)
// Assert
Assert.assertFalse(output)
}
@Test
fun `WHEN email is entered first time, THEN returns true`(){
// Arrange
val admin = Admin()
val classroom = Classroom(admin)
// Act
val output = classroom.add("sagarkhurana00786@school.com", Type.STUDENT)
// Assert
Assert.assertTrue(output)
Assert.assertTrue(admin.get("sagarkhurana00786@school.com"))
}
}
Code breakdown
Let’s take the second test case here as well (WHEN email is entered first time, THEN returns true
) for the explanation purpose.
Starting with the Arrange phase:
On line 1, we just define the email needed to pass as the input to the MUT.
On line 2, here we are using the object instance instead of the mocked object as we did in the London approach.
On line 3, we are setting up the precondition so that it justifies the unit test behavior, for example here we are checking that if the email already exists then MUT should return
false
so before invoking the MUT we are adding the value into theAdmin
class to simulate that environment.On line 4, we are setting up the SUT with the necessary input i.e
Admin
class.
Coming to the Act phase:
Invoking the MUT i.e add()
the method by passing inputs email
and type
both of them are value objects and save the MUT’s output of the assert phase.
Then Assert phase:
- We verify the return value of the MUT but also verify the change in the state of the
Admin
class
Advantages
Produced tests that are focused on the output or behavior of the state which makes it decoupled from the implementation.
Promotes high cohesion (loose coupling) as it's not dependent on the interaction.
Drawbacks
- Redundant Coverage is an anti-pattern, which is associated with this approach where multiple tests address the same functionality, and if that code breaks all tests fail which is considered a waste of resources.
Final Verdict
So, it's not like one over the other, buts it's a bit of both you’ve to consider the advantages and drawbacks of both then make a decision.
For example, if you consider the London school approach then you give to understand the risk the false positives which can be created due to future refactoring but this risk impacts only a small subset of tests if your codebase is modular and you are not doing a lot of things in one class itself. And, if you consider the Classical school approach then you have to understand the risk of redundant coverage but if you are running your test on every PR then it wouldn't take that long to spot the bug. Another point you can consider also that if your internal design is a product of refactoring then you can consider using the Classical approach because you found yourself often refactoring that class, or if your internal design is a product of upfront design then you can consider the London school approach.
I personally prefer London school if my code is properly modularized and using dependency why because again if I changed the code it will impact a small number of tests and all the other tests behind the interface are safe and I use the classical approach for integration tests to check the proper communication between different components.
So, what you should use well depends according to your situation (as every engineer says 🤓). Now that you know you can make your own choice.
Thanks to Mihir, Nishant, Yogesh, and prabhatexit0 for reviewing my draft to give feedback for corrections and suggestions.
Please share your feedback and suggestions in the comments and correct me if you have differing views. Please show your support by giving some 👏; it will boost my confidence to keep going and share it with other engineers; make sure to subscribe to get notified for the next part and follow for more.
I'm always up for discussing anything cool; feel free to connect.
LinkedIn: hellosagar
Twitter: hellosagarCode
Github: hellosagar