Unit-testing – How to write unit tests for code with difficult to predict results

tddunit testing

I frequently work with very numeric / mathematical programs, where the exact result of a function is difficult to predict in advance.

In trying to apply TDD with this kind of code, I often find writing the code under test significantly easier than writing unit tests for that code, because the only way I know to find the expected result is to apply the algorithm itself (whether in my head, on paper, or by the computer). This feels wrong, because I am effectively using the code under test to verify my unit tests, instead of the other way around.

Are there known techniques for writing unit tests and applying TDD when the result of the code under test is difficult to predict?

A (real) example of code with difficult to predict results:

A function weightedTasksOnTime that, given an amount of work done per day workPerDay in range (0, 24], the current time initialTime > 0, and a list of tasks taskArray; each with a time to complete property time > 0, due date due, and importance value importance; returns a normalized value in range [0, 1] representing the importance of tasks that can be completed before their due date if each task if completed in the order given by taskArray, starting at initialTime.

The algorithm to implement this function is relatively straightforward: iterate over tasks in taskArray. For each task, add time to initialTime. If the new time < due, add importance to an accumulator. Time is adjusted by inverse workPerDay. Before returning the accumulator, divide by sum of task importances to normalize.

function weightedTasksOnTime(workPerDay, initialTime, taskArray) {
    let simulatedTime = initialTime
    let accumulator = 0;
    for (task in taskArray) {
        simulatedTime += task.time * (24 / workPerDay)
        if (simulatedTime < task.due) {
            accumulator += task.importance
        }
    }
    return accumulator / totalImportance(taskArray)
}

I believe the above problem can be simplified, while maintaining its core, by removing workPerDay and the normalization requirement, to give:

function weightedTasksOnTime(initialTime, taskArray) {
    let simulatedTime = initialTime
    let accumulator = 0;
    for (task in taskArray) {
        simulatedTime += task.time
        if (simulatedTime < task.due) {
            accumulator += task.importance
        }
    }
    return accumulator
}

This question addresses situations where the code under test is not a re-implementation of an existing algorithm. If code is a re-implementation, it intrinsically has easy to predict results, because existing trusted implementations of the algorithm act as a natural test oracle.

Best Answer

There are two things you can test in difficult-to-test code. First, the degenerate cases. What happens if you have no elements in your task array, or only one, or two but one is past the due date, etc. Anything that is simpler than your real problem, but still reasonable to calculate manually.

The second is the sanity checks. These are the checks you do where you don't know if an answer is right, but you definitely would know if it's wrong. These are things like time must move forward, values must be in a reasonable range, percentages must add up to 100, etc.

Yes, this isn't as good as a full test, but you'd be surprised how often you mess up on the sanity checks and degenerate cases, that reveals a problem in your full algorithm.