0

I'm encountering a recurring challenge when using TDD to develop code that must perform actions in a specific order. Usually I'm able to write tests such that it is only possible to create a correct solution. However, when I need to test code that is required to perform actions in a specified sequence this becomes more complex that I would want.

Let's take the example of a program that has to send AT commands and process the responses. Somewhere in the code you'll eventually find a function that takes a command as an argument, and returns the received response.

Here is a test case that I would write for such a function:

def test_exchange_sends_a_command_and_returns_the_received_response():
    stdout = io.StringIO()
    stdin = io.StringIO("CONNECT\r\n")

    response = exchange(stdout, stdin, "ATD")

    assert stdout.getvalue() == "ATD\r\n"
    assert response == "CONNECT"

A correct implementation would be:

def exchange(stdout, stdin, command):
    stdout.write(command + "\r\n")
    return stdin.readline().strip()

However, the following incorrect implementation would also pass the test:

def exchange(stdout, stdin, command):
    response = stdin.readline().strip()
    stdout.write(command + "\r\n")
    return response

One potential solution is to implement a stateful test double. For example:

def test_exchange_command_sends_command_and_receives_response():
    stdin = io.StringIO()

    class StdoutStub(io.StringIO):
        def write(self, buf):
            super().write(buf)
            if self.getvalue() == "ATD\r\n":
                stdin.write("CONNECT\r\n")
                stdin.seek(0)

    stdout = StdoutStub()

    response = modemcontrol.exchange(stdout, stdin, "ATD")

    assert stdout.getvalue() == "ATD\r\n"
    assert response == "CONNECT"

There are probably better ways to implement this test double, but in essence the same logic would be necessary.

Another solution which turned out to work is using Rust async code. A full example is way too complex, but it would boil down to something like this:

let exchange_task = tokio::spawn(
    async move { exchange(stdout_stub, stdin_stub, "ATD").await }
);

// Check if command was send.
assert_eq!(stdout_stub.peek(), "ATD\r\n");

// Make the response available to the function
stdin_stub.put("CONNECT\r\n")

// Check response
let response = exchange_task.await.unwrap();
assert_eq!(response, "CONNECT");

I certainly wouldn't want to start using async in a project only to make the tests more simple. And I'm not sure if I would consider the code above 'simple' anyway.

Yet another option would probably be to use an event driven design. But again, that would result much more code.

Does anybody has a common pattern to use to test these kinds of functions?

2 Answers 2

2
def exchange(stdout, stdin, command):
    stdout.write(command + "\r\n")
    return stdin.readline().strip()

Suggestion #1 - always keep in the back of your mind that, when you are dealing with code that is "so simple there are obviously no deficiencies", you have the option of using techniques other than tests to control "the gap between decision and feedback".

In other words, give some thought to whether this is a problem worth solving, before investing deeply in the solution.


Most of the early work on this sort of exercise came out of the London Extreme Tuesday Club

Rough summary: what you have here is a protocol which interacts with a collection of capabilities, and you are trying to extract information about how the capabilities are used.

The solution usually starts from the pattern of creating an information hiding interface for the capabilities, which acts as a firewall between the protocol and the details of the general purpose code that actually does the interesting work.

def exchange(information_hiding_interface, command):
    information_hiding_interface.write(command + "\r\n")
    return information_hiding_interface.readline().strip()

So to extract the information that you need, you "just" pass an information_hiding_interface that collects the information that you want -- in other words, you implement telemetry.

Telemetry is of design -- Doctrine of Useful Objects

A "stateful test double" (George Mezaros would probably call this a test spy) is one way to achieve the result. But you might also come to decide that keeping track of a history of interactions is in fact a capability that belongs in your product code, and modify you design appropriately.

(Note that in this latter case, you're likely to end up with two different suites of tests: one suite that verifies that your telemetry collection logic is correct, and then another suite that verifies that your protocol delegates work correctly to your capabilities, using your verified telemetry as a source of "truth").


Admittedly, to some degree this is a shell game. For example, if we think about what information_hiding_interface looks like, we might imagine something like


def write(prompt):
    self.telemetry.write(prompt)
    self.stdout.write(prompt)

Oh no, how do we test drive that? My answer is that for parts of the design where it is "so simple that there are obviously no deficiencies" and super stable (meaning we're much more likely to delete the code in question rather than modify it), we'll lean on human verification for the developer loop and system testing to verify overall system correctness. The justification is that the return on investment for tests at this grain is absolute trash.

This does, perhaps, violate the "write a failing automated test before you write any code" rule. After years of practice, I've discovered that I don't regret violating the rule in this context.

Sign up to request clarification or add additional context in comments.

Comments

-3
class StdoutStub(io.StringIO):
    def __init__(self, stdin):
        super().__init__()
        self.stdin = stdin
        self.sent_command = False

    def write(self, buf):
        super().write(buf)
        if buf == "ATD\r\n" and not self.sent_command:
            self.sent_command = True
            self.stdin.write("CONNECT\r\n")
            self.stdin.seek(0)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.