Write Testable Code
How and Why?
Why writing tests?
Tests reduce bugs in new features
- Force developers to think twice about their code
- Edge cases are more likely to be tested
- Consequence: improved robustness
Tests reduce bugs in existing features
- Existing tests will fail while adding new features
- Without tests, new bugs may take days or weeks to be discovered
Tests are good documentation
- A concise code example is better than many paragraphs of documentation
- Need to learn a new API? Look at the existing tests!
Tests reduce the cost of change
- Developers have confidence that changes do not break existing functionality
- More time can be dedicated to coding, instead of manual testing
- Poorly-tested software becomes increasingly expensive to change as time goes on (manual testing becomes very painful and bugs comes more often)
Tests improve design
- Writing tests forces you to make your code testable
- Reduces coupling in your application, as well as dubious patterns like singletons and global variables
Tests helps refactoring
- Without tests, refactoring is pretty scary
- Refactoring is a necessity to improve a software among the years
Tests defend against other devs
- A good test is more valuable than 10 lines of comment
- Programmers constantly write weird code to fix weird bugs... tests can help you make sure no one will delete that code thinking it's a mistake!
Many other reason
- Tests reduce fear when changing the code of an employee / intern that left the company
- Testing is fun and challenging!
- Tests make development faster on the long run
What types of tests?
Unit Tests
- Repeatable: you can rerun the same test as many times as you want
- Consistent: every time you run it, you get the same result
- In memory: it has no "hard" dependencies on anything not in memory (such as file system, databases, network)
- Fast: It should take less than half a second to run
- Check one single concern or "use case" in the system
Integration tests
- Reach out to external systems or local machine dependencies (web services, database, files)
- Test multiple things in the course of one test case
- Are usually much slower than unit tests
- Are more likely to fail randomly
So then why integration tests?
- Extra "smoke test" that can be run when the full system needs to be tested
- It is usually hard or impossible to write unit tests for legacy code (i.e. code that does not have tests), as it is mostly untestable
So, how do I write testable code?
Rule #1: Avoid constructors that do too much
- New keyword in a constructor or at field declaration
- Static method calls in a constructor or at field declaration
- Anything more than field assignment in constructors
- Object not fully initialized after the constructor finishes (watch out for 'initialize' methods)
- Complex object graph construction inside a
constructor rather than using a factory or builder - Conditional or looping logic in a constructor
Why is that an issue?
- Tight coupling
- Prevents reusing class with different setup
- Prevents injection of dependencies for testing
Use “new” keyword in constructor (or field declaration)
public class House
{
private Kitchen kitchen = new Kitchen();
private Bedroom bedroom;
public House()
{
this.bedroom = new Bedroom();
}
}
public class House
{
private Kitchen kitchen;
private Bedroom bedroom;
@Inject
public House(Kitchen k, Bedroom b)
{
this.kitchen = k;
this.bedroom = b;
}
}
public void TestThisIsReallyHard()
{
House house = new House();
// Darn! I'm stuck with those Kitchen and
// Bedroom objects created in the
// constructor...
}
public void testThisIsEasyAndFlexible()
{
Kitchen dummyKitchen = new DummyKitchen();
Bedroom dummyBedroom = new DummyBedroom();
House house =
new House(dummyKitchen, dummyBedroom);
// Awesome, I can use test doubles that
// are lighter weight...
}
Network calls / static calls in constructor (WTF!)
public class AccountView
{
private User user;
public AccountView()
{
// Static methods are non-interceptable,
// and non-mockable
this.user = DatabaseManager.GetCurrentUser();
}
}
public class AccountView
{
private User user;
public AccountView(User user)
{
this.user = user;
}
}
// Hard to test because needs real database
public void TestWithRealDatabase()
{
AccountView view = new AccountView();
// Dammit! We just had to connect to a
// real database. This test is now slow.
}
// Easy to test with dependency injection
public void TestLightweightAndFlexible()
{
User user = new DummyUser();
AccountView view = new AccountView(user);
// Easy to test and fast with test-double user
}
Rule #2: Avoid digging into collaborators
- Objects are passed in but never used directly (only used to get access to other objects)
- Suspicious names: context, manager, helper, ...
Service object digging around in value objects
public class TaxCalculator {
private TaxTable taxTable;
public SalesTaxCalculator(TaxTable taxTable) {
this.taxTable = taxTable;
}
float ComputeSalesTax(User user, Invoice invoice) {
// Note that "user" is never used directly
Address address = user.Address;
float amount = invoice.SubTotal;
return amount * taxTable.GetTaxRate(address);
}
}
public class TaxCalculator {
private TaxTable taxTable;
public SalesTaxCalculator(TaxTable taxTable) {
this.taxTable = taxTable;
}
float ComputeSalesTax(Address address, float amount)
{
// No user, nor digging inside the invoice
return amount * taxTable.GetTaxRate(address);
}
}
void PainfulTaxCalculatorTest()
{
TaxCalculator calc = new TaxCalculator(new TaxTable());
// Testing exposes the problem by the amount
// of work necessary to build the object graph
Address address = new Address("1600 Amphitheatre Parkway...");
User user = new User(address);
Invoice invoice = new Invoice(1, new ProductX(95.00));
Assert.AreEqual(0.09,
calc.ComputeSalesTax(user, invoice));
}
void QuickTaxCalculatorTest()
{
TaxCalculator calc = new TaxCalculator(new TaxTable());
// Only wire together the objects that are needed
Address address = new Address("1600 Amphitheatre Parkway...");
Assert.AreEqual(0.09,
calc.ComputeSalesTax(address, 95.00));
}
Use of "context" object containers
public class MembershipPlan
{
public void ProcessOrder(UserContext userContext)
{
User user = userContext.User;
PlanLevel level = userContext.Level;
Order order = userContext.Order;
// Do stuff
}
}
public class MembershipPlan
{
public void ProcessOrder(User user,
PlanLevel level, Order order)
{
// Do stuff
}
}
public void TestWithContext()
{
UserContext userContext = new UserContext
{
User = new User("Kim"),
Order = new Order("SuperDeluxe", 100, true),
Level = new PlanLevel(143, "yearly")
}
MembershipPlan plan = new MembershipPlan();
plan.processOrder(userContext);
// Then make assertions against the user, etc ...
}
public void TestWithApiDeclaringWhatItNeeds()
{
User user = new User("Kim");
PlanLevel level = new PlanLevel(143, "yearly");
Order order = new Order("SuperDeluxe", 100, true);
MembershipPlan plan = new MembershipPlan();
plan.processOrder(user, level, order);
// Then make assertions against the user, etc ...
}
Rule #3: Static code is the enemy of automated testing!
- Cheap and immediately available (no need to call 'new')
- Can be called from anywhere
- Good for Simple calculations / processing (ex: Math.Abs())
- Unique implementation: no polymorphism!
- Life time = application (no construction, no destruction)
- Memory usage: no garbage collection
- Static variable = global state
- Not object-oriented, definitely procedural
- Encourage tight coupling
- Almost impossible to mock in C#
- Make APIs lie about their true dependencies
Why can't I unit test this code?
/// <summary>
/// Checks if a mailbox has any error.
/// </summary>
/// <param name="mailbox">The mailbox.</param>
/// <returns>True if has errors.</returns>
public static bool HasErrors(Mailbox mailbox)
{
// Data validation
Errors.Assert(null != mailbox);
Errors.Assert(Guid.Empty != mailbox.Id);
// Get the ticket
Ticket ticket = new Ticket(WebAuthUtils.User.Id, false);
// Get migration stats
MailboxStat mailboxStat = Mailbox.GetMigrationStat(mailbox.Id);
// Return true if there are too many errors: more than 20 and more than 3%
const int minErrors = 20;
const double minErrorPercentage = 0.03;
return (minErrors < mailboxStat.TotalErrorCount)
&& (minErrorPercentage * mailboxStat.TotalErrorCount < mailboxStat.TotalErrorCount);
}
Here is a more testable version
public class TheSurroundingClass
{
public IDataManager DataManager { get; set; }
public TheSurroundingClass(IDataManager dataManager)
{
this.DataManager = dataManager;
}
public bool HasErrors(Mailbox mailbox)
{
// Data validation
Errors.Assert(null != mailbox);
Errors.Assert(Guid.Empty != mailbox.Id);
// Get the ticket
Ticket ticket = new Ticket(WebAuthUtils.User.Id, false);
// Get migration stats
MailboxStat mailboxStat = this.DataManager.Find("MailboxStat", mailbox.Id);
// etc.
}
}
And now what?
public interface IDataManager
{
public Entity Find(string entityName, Guid id);
}
public class SqlDataManager
{
public Entity Find(string entityName, Guid id)
{
// Do some SQL crap
}
}
public class FakeDataManager
{
public Entity Find(string entityName, Guid id)
{
// Return in-memory fake object
}
}
We can implement a fake data manager!
And now what?
// Create a new Data Manager mock
Mock<IDataManager> dataManagerMock = new Mock<IDataManager>();
// Create a fake mailbox stat object
MailboxStat mailboxStat = new MailboxStat() { Speed = 12, Foo = "Bar" };
// Setup the mock to return fake mailbox stat (version 1)
dataManagerMock.Setup(p => p.Find(It.IsAny<string>(), It.IsAny<Guid>())).Returns(mailboxStat);
// Setup the mock to return fake mailbox stat (version 2)
dataManagerMock.Setup(p => p.Find("MailboxStat", It.IsAny<Guid>())).Returns(mailboxStat);
We can even mock the data manager!
public interface IDataManager
{
public Entity Find(string entityName, Guid id);
}
public class SqlDataManager
{
public Entity Find(string entityName, Guid id)
{
// Do some SQL crap
}
}
Tight coupling: example 1
public class FooContext : IFooContext
{
private FooContextState state;
private ReaderWriterLockSlim theLock;
private FooContextDescriptor internalObject;
private ReaderWriterLockSlim anotherLock;
public BarManagement BarManager { get; private set; }
public OtherManagement OtherManager { get; private set; }
internal FooContext(FooContextType fooType, Guid userId)
{
this.internalObject = FooContextUtility.CreateFooDescriptor(fooType, userId);
this.Initialize();
}
private void Initialize()
{
this.theLock = new ReaderWriterLockSlim();
this.BarManager = new BarManagement(this);
this.OtherManager = new OtherManagement(this);
this.PlopManager = new PlopManagement(this);
this.EvenMoreManager = new EvenMoreManagement(this);
this.anotherLock = new ReaderWriterLockSlim();
this.state = this.internalObject.State;
}
}
Tight coupling: example 2
// Hard to test
public static long DetermineSomething(Guid licensePackId)
{
// Get the license pack
LicensePack licensePack = LicensePack.Retrieve(licensePackId);
Errors.AssertNotNull(licensePack);
// Return 0 if blah
if (licensePack.Blah)
return 0;
// Get invoice
Guid invoiceId = licensePack.InvoiceId;
Invoice invoice = Invoice.Retrieve(invoiceId);
// Do more stuff
}
// Easier to test
public static long DetermineSomething(LicensePack licensePack, Invoice invoice)
{
// Return 0 if blah
if (licensePack.Blah)
return 0;
// Do more stuff
}
Tight coupling: example 3
public MigrationManager(int maxApiCallRetryCount = 2)
{
// Set members
this.WebService = new WebService
{
Url = ConfigurationManager.Read<string>("WebApiUrl").Value
};
this.MaxApiCallRetryCount = maxApiCallRetryCount;
}
Real Life Example
The most painful test I ever wrote
Goal: Test the refund REST service endpoint
- Integration test
- Already tested manually and working
- Requires a lot of dependencies
Text
Text
Refund Credit Card - Call Graph
Assumptions
- Database available (several)
- Network available
- File system available (config file, email templates)
Issues
- Lot of static classes
- No visibility on the dependencies
- Impossible to mock / change implementation
- Impossible to unit test
- Test are slow and can easily break:
- Several database calls / network calls
- Many file access
- Used to require an SMTP server!
Solution(-ish)
- Bootstrap MigrationWiz and Lookup databases
- Create a test organization and a test user
- Authenticate that user
- Create a test invoice and a test license pack
- Switch Authorize.Net client in test mode
- Mock the mailer to avoid file access
- Make sure emails are not being sent
Possible improvements
- Inject dependencies:
- Pass them in constructor
- Use factories
- Use dependency injection framework
- Stop using a database:
- Switch to an in-memory database (fancy)
- Implement / mock a fake DataAccessManager
- Stop calling Authorize.Net (they have their own tests!)
What can we do?
- Use static only when it makes sense
- Avoid "hardcoding" dependencies: pass them instead!
- De-staticify core classes (DataAccessManager, LicenseManager, ...)
- Deploy a continuous integration platform
- Add testing into our development practices
- Assess testability during code reviews
Sources
Thanks!
Write Testable Code
By Guillaume Zurbach
Write Testable Code
- 833