TDD stands for test driven development. This translates to tests being written BEFORE the actual code. Let’s look at some of the code smells(and their possible remedies) which happen especially when tests are written AFTER the actual code.
Lots of setup
These are the tests where a lot of setup is required to get to the part where you can actually invoke the code under test.
Solution – A corrective measure would be to use more abstraction and separation of concerns.
Multiple asserts
There are multiple asserts spread out in a single test method. Due to this it becomes difficult to figure out what caused the test to fail. e.g. If a method fails at 8th assert statement, now what was the reason of the failure? Was it the 8th assert? Was it something that happened before the 8th assert? Or was it something that happened before the 3rd assert.
@Test
public void testMethod() {
//some mocking or setup code
// FIRST action call which will invoke the method under test
// assert 1
// assert 2
// some setup
// SECOND action call which will invoke the method under test
// assert 3
// assert 4
// assert 5
// some more setup
// THIRD action call which will invoke the method under test
// assert 6
// assert 7
// assert 8
}
Solution – A test should be responsible for testing one use-case. Therefore, break your tests into smaller tests.
@Test
public void testUseCaseOne() {
//some mocking or setup code
// FIRST action call which will invoke the method under test
// assert 1
// assert 2
}
@Test
public void testUseCaseTwo() {
// some setup
// SECOND action call which will invoke the method under test
// assert 3
// assert 4
// assert 5
}
@Test
public void testUseCaseThree() {
// some setup
// THIRD action call which will invoke the method under test
// assert 6
// assert 7
// assert 8
}
Test helper methods
Some methods only exist for the purpose of tests. This cries out loud that the tests were added after the code was written and not driven through TDD.
Solution – This would be case to case basis. But figuring out a way to inject dependencies reduces the need for having test specific code in the code under test.
Tight coupling → Brittle tests
Here coupling refers to one between tests and the code being tested. A small change in the actual code ends up breaking a lot of tests. This eventually starts making changes to the code a painful process, bringing development to such areas to a halt.
Solution – Corrective measures differ on case to case basis. May be the tests are written for each and each every private method. Focusing on testing publicly exposed contracts/boundaries also helps in resolving such issues. Or may be coupling is the reason in which case abstraction can be used to decouple the code on a step by step basis.
Excessive mocking
Some tests require a lot of mocks/mocking. It may so happen that ultimately you are trying to test mocked objects only. Such tests are also pretty brittle and fragile when it comes to code changes. Let’s look at an example to understand this.
public class A {
private String name;
private int age;
...
//setters and getters
A (JNIObject jniObject) {
s1 = jniObject.getName();
s2 = jniObject.getAge();
...
}
}
//Test
public class TightCouplingTest {
@Test
public void testMethod() {
JNIObject mockJniObject = mock(JNIObject.class);
when(mockJniObject.getName()).thenReturn("some name");
when(mockJniObject.getAge()).thenReturn(20);
A a = new A(mockJniObject);
...
}
}
In the above example, let’s assume that JNIObject is provided by a 3rd party library which further requires some complex object creation logic that is proprietary to that library. For the test to be able to create an object of class A, you would need create a number of mocks for accessing each property which is being read from jniObject. It seems ok if there are only 2 properties as in this example. But this becomes a pain when the number of properties are more. Moreover, object A is now getting created from a mocked object which really makes this a fragile test.
Solution – Need for high number of mocks usually implies tight coupling and a gap in separation of concerns. As a corrective measure the design of the code needs to be reviewed to fix this problem.
In the above example, instead of directly depending on JNIObject, Class A can be modified to depend on a contract rather than an a concrete class. An adapter can be used for example for the transformation.
public interface TestableDependency {
String getName();
int getAge();
}
public class ModifiedA {
private String name;
private int age;
...
//setters and getters
A (TestableDependency testableDependency) {
s1 = testableDependency.getName();
s2 = testableDependency.getAge();
...
}
}
//Test
public class TightCouplingTest {
@Test
public void testMethod() {
TestableDependency testableDependency = new TestableImplementation("some name", 20);
A a = new A(testableDependency);
...
}
}
Lame tests
Some of the tests/asserts simply don’t make much sense. Such tests exist usually because there was a parameter set by someone up in the hierarchy that the coverage should be 80% etc.
Solution – Such tests can very well be deleted if they are not adding much value to the suite.
Tests with above problems tell that there are design issues in the code. TDD is a great way of getting very fast feedback on the design of your code.
It is definitely better to write test after writing the code rather than not writing tests at all. But this should be a temporary phase for your team. Your team should strive to move on to writing tests BEFORE writing the actual code.