Pay the technical debt back
By "too large a number" I mean 5000+ lines of code to test one class
[...]
how do I keep the number of test cases for an object with many collaborators from growing too large?
This will sound obvious, but worth saying. divide and conquer. Get rid of the God Object antipattern first. Otherwise, you won't be addressing the real problem. A piece of source code with 5000 LoC is claiming attention and refactoring.
See if you can group up collaborations around behaviours or reasons to collaborate so you can build abstractions around them. Cut down GodObject's complexity ASAP.
Don't repeat yourself
And how do I keep the setup of each test case from growing too large?
Make reusable test code the same way you do with production code. Some things you can do
Utils & Static Factory Methods
These classes have methods with very concrete names recalling the ubiquitous language. Names tell what they populate or do (usually, prepare an object graph in a specific state and ready for a limited number of execution paths).
Complex graphs are built by calling several factory methods, each of which is simpler than the one orchestrating the calls.
Extends the test framework
How does the framework works? Does it allow you to implement custom assertions and predicates? If yes, craft custom ones around the domain's ubiquitous language.
public class MyGodObjectTest {
@Test
public void given_state_S_when_X_then_Y() {
Object value = MyGodObjectDSL.createGodObjectS().doX();
assertThat(value, eq(y));
}
@Test
public void given_state_S_when_Z_then_W() {
MyGodObject god = MyGodObjectDSL.createGodObjectS();
god.doZ(input);
/*
* isInStateW():
* - will do all the assertions involving state W.
* - can replace duplicated validations all over the test code.
* - can be parametrized to make it even more reusable
*/
assertThat(god, isInStateW());
}
@Test
public void given_state_S_when_Y_then_failure_due_to_MyException() {
MyGodObject god = MyGodObjectDSL.createGodObjectS();
// Example of custom assertion
assertThrows(() -> god.doY(input))
.logThrowable(LOGGER)
.isInstanceOf(MyException.class)
.hasErrorCode("Code")
.hasErrorCode("Message");
}
}
The goal is to keep test methods clean! Get rid of repeated code. DRY applies to test code too.
Rearrange the test code
then, each test case modifies the setup, meaning that I cannot understand all of the inputs to a single test case just by reading the test case itself
Instead of 300 unit tests in a single class, make different test suites. Each test suite focuses on specific states of the object, state transitions or execution paths.
Say the number of sad execution paths is significantly higher than happy ones. In that case, you could make one test suite solely for sad paths and another for happy ones.
Add some literature
The tests for this class are difficult to maintain because it takes me hours to read and understand what each of the test cases is testing
Naming things is hard. Right?
Allow yourself to break the naming conventions of your programming language and give test methods names some literature. Your attention should be on the given,when and then, not on everything that comes before. How to prepare GodObject for the test is useless and irrelevant code unless it's part of the test case itself.
public void testSomethingNotDocumentedSoHopeForTheBest(){ ... }
// vs
/**
* MyGodObject goes into state "S" when ...
* In such a state, consumers are allowed to ... only.
* Unallowed actions are expected to break the execution and result in a {@link MyException}
*/
public class MyGodObjectStateSTest
public void given_state_S_when_X_then_Y(){ ... }
public void given_state_S_when_X_and_Z_then_Y(){ ... }
public void given_state_S_when_X_but_no_Z_then_W(){ ... }
}
The "literature" can also ease finding code by IDE's lookups.