Friday, 24 March 2023

CFML / TestBox: spying on a method without mocking it

G'day:

Whilst looking for something else, I came across a ticket in TestBox's Jira system the other day that I had voted for a while back: Ability to spy on existing methods: $spy(). About a year or so ago I really had a need for this - hence finding the issue in there in the first place - sadly it was not around that the time, but I'm pretty pleased to see it's coming to TestBox 5.x soon (it still seems to be shipping to ForgeBox as a snapshot for now, but it's installable).

I can't actually remember what I needed to be testing back when this first became relevant for me, but I work on a legacy application which was never written with testing in mind, and we need to spend a lot of time horsing about with mocking stuff out so as to be able to test code adjacent to stuff that absolutely won't run in a test environment. That's fine if one just wants to mock something away: TestBox has that nailed. But sometimes - sometimes - with legacy code one really needs to leave a piece of code running - its side-effects might be essential to part of the test for example - but also see what arguments it gets passed, probably because we're dealing with an method that hundreds of lines long, and the change we're making hits logic in multiple parts of it. In the perfect world we'd refactor code like that before we test it, but a) we don't reside in the perfect world; b) if you don't already have tests (we don't), then it's not "refactoring", it's "just changing shit". Sometimes we have to accept further technical debt and not "just change shit" to make testing easier. I really hate this, but it's a reality we have.

Anyway. The way one does this in a test framework is via spying. There's plenty of writing out there that differentiates between stubs, doubles, mocks and spies, but in my opinion that thinking is itself largely legacy. These days doing any of that is likely to be effected via a dedicated framework (like MockBox embedded in TestBox), and it's more a difference in usage rather than being anything really that different.

Let's have a look at some code.

For this exercise, I have this simple function to monkey about with:

public string function reverseThisString(required string stringToReverse) {
    return stringToReverse.reverse()
}

It reverses a string. Yeah I know CFML already has a function to do that (I'm using it here!), it's just something easy to use for some tests.

A baseline test here is simply to test it works:

it("shows the reverseThisString working as a baseline", () => {
    sut = new SpyTest()

    result = sut.reverseThisString("G'day world")

    expect(result).toBe("dlrow yad'G")
})

Yep. It works.

Oh, one thing to note in all these tests is that reverseThisString is actually within my test suite - SpyTest - so I'm actually instantiating an instance of the very class the test is running from (and then later mocking it and stuff). It's important to remember the instance of the class being used in the test run is not the same as the one I'm testing in the test run. if that makes sense.

Next let's demonstrate that preparing an object for mocking doesn't actually disable an object's methods at all:

component extends=BaseSpec {

    function beforeAll() {
        variables.mockbox = getMockBox()
    }

    function run() {
        describe("Tests $spy function in TestBox", () => {
            // …

            it("shows how mocking an object does not impact its methods", () => {
                sut = new SpyTest()
                mockbox.prepareMock(sut)

                result = sut.reverseThisString("G'day world")

                expect(result).toBe("dlrow yad'G")
            })
            
            // …
        })
    }
    // …
}

reverseThisString is still doing it's thing. It's not mocked.

And a quick look that a mocked object has a call log, but the call log doesn't include unmocked methods (only the mocked ones):

it("shows how an object's callLog does not include non-mocked methods", () => {
    sut = new SpyTest()
    mockbox.prepareMock(sut)
    sut.$("mockMe")

    sut.reverseThisString("G'day world")
    sut.mockMe()

    callLog = sut.$callLog()

    expect(callLog).toHaveKey("mockMe")
    expect(callLog).notToHaveKey("reverseThisString")
})

Oh yeah, there's a mockMe method in there too:

public void function mockMe() {
    throw "Test is invalid: this should be mocked-out"
}

So this is the crux of it. Mocked methods have call logs, so we are in effect spying on them all the time. But unmocked methods: no.

No - to state the obvious, I hope - if one mocks a method, it does not do anything:

it("shows how mocking a method prevents it from executing", () => {
    sut = new SpyTest()
    mockbox.prepareMock(sut)
    sut.$("reverseThisString")

    result = sut.reverseThisString("G'day world")

    expect(isNull(result)).toBeTrue()
})

Nuff said.

OK, so here's the solution: this new $spy functionality:

it("shows how spying a method leaves it operational, and has a call log", () => {
    sut = new SpyTest()
    mockbox.prepareMock(sut)
    sut.$spy("reverseThisString")

    result = sut.reverseThisString("G'day world")

    expect(result).toBe("dlrow yad'G")

    callLog = sut.$callLog()

    expect(callLog.reverseThisString[1][1]).toBe("G'day world")
})

Here I am just spying on my method, not mocking it; so when I call it: it still works. But I also have the call log. Job done.

That's all there is to that. I hasten to add that this sort of feature is only useful to have occasionally, but sometimes with untestable legacy code it's a life-saver.

I would say that if you need to use this when testing new code that you're developing, yer likely doing something wrong. That said, if you have a real-world example where this is useful when testing well-written new code, please share.

And the code for this effort is here: SpyTest.cfc.

Righto.

--
Adam