0

Where/how do you draw the line for unit-testing classes at different levels of a hierarchy? For example, let's say you have a structure like this:

public class Account
{
    public Money CurrentBalance { get; } => ComputeBalance(_transactions);

    public Money BalanceByDate(DateTime date)
    {
        // ComputeBalance through date
    }
    // More accounting functions
}

public class Ledger
{
    public void CreateNewAccount(/* Account Details */)
    {
        // Create a new account
        _accounts.Add(account);
    }

    public Account GetAccountByName(AccountName name)
    {
        // return an account if/when found by name
    }

    public void PostTransaction(Transaction transaction)
    {
        // add a transaction to two different accounts
    }

    // More ledger functions

    private readonly List<Account> _accounts = new List<Account>();
}

I have an AccountTests class that obviously tests account functions like CurrentBalance, BalanceByDate(), etc. However, the question is:

Q) How much of Ledger do I need to test? Do I just check that Ledger returns an Account? Or do I need to check all of Account functions again since Ledger will post transactions to 2 different Accounts (e.g. check CurrentBalance again but this time for each pair of Accounts (Credited account and debited account)).

I'm doing TDD (albeit not sure how well), and I feel like I'm testing multiple things over and over.

  • 1
    To my experience, in software development, the whole is not the sum of the parts. Some unit tests will overlap so that certain blocks of code will be tested more than others. I don't see a problem on it. – Laiv Nov 13 '18 at 6:21
  • It you are doing TDD then every test you wrote was testing a new bit of functionality. So what are you testing over and over? – Goyo Nov 13 '18 at 15:12
2

Testing is a bit of a black art. Think of it as an explanation as to how to use your system, and any gotchas that you would tell another developer.

So in the your case with the ledger. You should have a test for each valid usage scenario. That is treat the object as a black box, any externally available function/property/method/type should be part of at least one valid usage of the object in production.

If there is something exposed that isn't part of a valid use of the object. That is a another developer should never reasonably call that function, then it is either located in the wrong place, or it is a candidate for deletion.

  • If the function is in the wrong place, move it. That is what the Tests are telling you to do.

  • If the function is not used, delete it. Dead code, is code that can give you a bad hair day. Don't keep it around just in case. The source code repository lets you look at it and revive it if you need it later.

That being said, the test should show something new. By new I mean that i could not have learnt that about the object through any of the other tests. Essentially don't repeat yourself.

Kevlin Henney has several presentations on youtube about errors, and testing. One is called GUTS, (Good Unit TestS). He is well worth the time to listen to.

So in pseudo-code (because the problem is not language specific)

Test("Find named account in ledger returns the account")
{
    var exisitingAccount = Account(<setup with name "abc">);
    var ledger = Ledger(exisitingAccount, <and others>);

    var account = ledger.GetAccountByName("abc");

    //if the account is returned by reference
    Expect(account).to.be(exisitingAccount);

    //if the account is returned by value
    Expect(account).to.equal(exisitingAccount);
}

Valid also covers expected "error" scenarios

Test("Find unknown named account in ledger [returns null|nullAccount]|[throws SomeException]")
{
    var ledger = Ledger();
    ledger.CreateNewAccount(<an account>);
    ledger.CreateNewAccount(<another account>);
    ledger.CreateNewAccount(<because this use case is legite>);
    ledger.CreateNewAccount(<accessing the accounts after they have been created>);

    //if the absence does not cause a thrown exception
    var account = ledger.GetAccountByName("abc");

    //if null means no account
    Expect(account).to.be(null);

    //if a special "null" account
    Expect(account).to.be(nullAccount);

    //or if it throws
    var exception = ExpectThrows(() => ledger.GetAccountByName("abc"));

    //always make sure the exception is correct, it is after-all a return value.
    Expect(exception.message).to.equal("important message");
}
2

Where/how do you draw the line for unit-testing classes at different levels of a hierarchy?

Jim Coplien made some interesting observations about using testing a few years back.

But what fraction of that interface is actually used by the application? And how much of the generalized engineering is really needed by the app?

A fully generalized behavior/data-structure will typically support many more use cases than are actually necessary within the context of a specific system. This is Weinberg's decomposition rule in action -- the part is more than a fraction of the whole -- exposing details of the system that would otherwise be hidden increases the testable surface area, but does not necessarily increase the value of the tests.

Against this, you have arguments like that of J.B. Rainsberger: Integrated Tests are a Scam.

Ultimately, I think these two talks by Gary Bernhardt most influenced by thinking:

My current position: once I get myself to a point that I'm working with a function, I stop worrying about the layers of hierarchy underneath.

How much of Ledger do I need to test? Do I just check that Ledger returns an Account?

Kent Beck, in 2008:

I get paid for code that works, not for tests, so my philosophy is to test as little as possible to reach a given level of confidence

Your Answer

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.