Friday 13 May 2022

CFML: adding aroundEach to TinyTestFramework was way easier than I expected

G'day:

I'm still pottering around with my TinyTestFramework. Last night I added beforeEach and afterEach handlers, but then thought about how the hell I could easily implement aroundEach support, and I could only see about 50% of it, so I decided to sleep on it.

After a night's sleep I spent about 30min before work doing a quick spike (read: no tests, just "will this even work?"), and surprisingly it did work. First time. Well except for a coupla typos, but I nailed the logic first time. I'm sorta halfway chuffed by this, sorta halfway worried that even though what I decided would probably work - and it did - I haven't quite got my head around how it works, or even quite what it's doing. So let's blog about that.

This evening after work I ignored my spike, and wrote some tests. This is another TDD lesson: it's OK to do a spike in yer code without tests and stuff to just prove a proof of concept, but then once yer good with that, put it to once side and go back to writing tests. My spike code is in a completely different environment from the environment I'm writing the tests in.

For the tests, I consider a lot of the testing of the hierarchical mechanics of how a describe / it testing framework works has already been tested extensively by the beforeEach tests (see "CFML: Adding beforeEach handlers to my TinyTestFramework. Another exercise in TDD"), and I don't need to restest that. This is the same reason I tested the afterEach stuff quite lightly too ("CFML: for the sake of completeness, here's the afterEach treatment"). In those afterEach tests I just tested the "afterness" of the way it works, which is the only difference in the implementation of afterEach compared to beforeEach. The chief difference being that when there is a hierarchy of afterEach handlers, they are called in the reverse order from equivalent beforeEach handlers.

For aroundEach what I am testing is the "aroundness" of how it works.

Here are the tests:

describe("Tests of aroundEach", () => {
    describe("Tests hierarchical sequencing", () => {
        result = []
        aroundEach((test) => {
            result.append("aroundEach top level before test")
            test()
            result.append("aroundEach top level after test")
        })
        describe("Tests hierarchical sequencing (second level: no aroundEach in this one)", () => {
            describe("Tests hierarchical sequencing (third level)", () => {
                aroundEach((test) => {
                    result.append("aroundEach third level before test")
                    test()
                    result.append("aroundEach third level after test")
                })
                describe("Tests hierarchical sequencing (inner level)", () => {
                    aroundEach((test) => {
                        result.append("aroundEach inner before test")
                        test()
                        result.append("aroundEach inner after test")
                    })
                    
                    it("is the baseline test", ()=> {
                        expect(true).toBeTrue()
                    })

                    it("tests the aroundEach handlers are called in the correct order", ()=> {
                        result.append("tests the aroundEach handlers are called in the correct order")

                        expect(result).toBe([
                            "aroundEach top level before test",
                            "aroundEach third level before test",
                            "aroundEach inner before test",
                            "aroundEach inner after test",
                            "aroundEach third level after test",
                            "aroundEach top level after test",
                            "aroundEach top level before test",
                            "aroundEach third level before test",
                            "aroundEach inner before test",
                            "tests the aroundEach handlers are called in the correct order"
                        ])
                    })
                })
            })
        })
    })

    describe("Tests with beforeEach and afterEach", () => {
        result = []

        afterEach(() => {
            result.append("set by afterEach")
        })

        beforeEach(() => {
            result.append("set by beforeEach")
        })

        aroundEach((test) => {
            result.append("set by aroundEach before test")
            test()
            result.append("set by aroundEach after test")
        })

        it("is the baseline test", ()=> {
            expect(true).toBeTrue()
        })

        it("tests the aroundEach handlers are called in the correct order", ()=> {
            result.append("tests the aroundEach handlers are called in the correct order")

            expect(result).toBe([
                "set by beforeEach",
                "set by aroundEach before test",
                "set by aroundEach after test",
                "set by afterEach",
                "set by beforeEach",
                "set by aroundEach before test",
                "tests the aroundEach handlers are called in the correct order"
            ])
        })
    })
})

So we have:

  • Test how a collection of hierarchical aroundEach handlers work. It looks like a lot of code, but it's just three nested describe blocks each with an aroundEach handler, before finally a test to observe, and a test that does the observation and tests some expectations on same.
  • Test that aroundEach plays nice with beforeEach and afterEach. Again a few lines of code, but pretty simple stuff, and the expectations are pretty clear and straight forward.

Um. OK. So.

The implementation bit is going to be a bit of an anti-climax after that lot. Firstly there's some scaffolding stuff that's obviously needed:

beforeEach = (callback) => {
    tinyTest.contexts.last().beforeEachHandler = callback
},
afterEach = (callback) => {
    tinyTest.contexts.last().afterEachHandler = callback
},
aroundEach = (callback) => {
    tinyTest.contexts.last().aroundEachHandler = callback
},
// ...
beforeEach = tinyTest.beforeEach
afterEach = tinyTest.afterEach
aroundEach = tinyTest.aroundEach

And the implementation is changing this:

it = (string label, function implementation) => {
    tinyTest.inDiv(() => {
        try {
            writeOutput("It #label#: ")

            tinyTest.contexts.each((context) => {
                context.keyExists("beforeEachHandler") ? context.beforeEachHandler() : false
            })

            implementation()

            tinyTest.contexts.reduce((reversedContexts, context) => reversedContexts.prepend(context), []).each((context) => {
                context.keyExists("afterEachHandler") ? context.afterEachHandler() : false
            })

            tinyTest.handlePass()
        } catch (TinyTest e) {
            tinyTest.handleFail()
        } catch (any e) {
            tinyTest.handleError(e)
        }
    })
},

To be this:

decoratedImplementation = tinyTest.contexts
    .filter((context) => context.keyExists("aroundEachHandler"))
    .reduce((reversed, context) => reversed.prepend(context), [])
    .reduce((decorated, context) => () => context.aroundEachHandler(decorated), implementation)

decoratedImplementation()

That's not much code, but it's a wee bit dense, even with putting each step on a separate line. What are we doing:

  • Taking the context collection.
  • Filtering out all the context objects with no aroundEachHandler element. We don't care about those.
  • Flipping the remainder around, as we need to apply them from the one nearest the test to the furthest.
  • Then, starting with the test implementation callback, sequentially pass it to the preceding callback in the hierarchy, if that makes sense. It's kind of a partial-application of each aroundEach callback I guess.
  • Once we've done that, call the returned function.

In effect what I think I am doing is calling a function that calls each aroundEachHandler callback from outermost to innermost, passing it the next handler down as its argument, all the way until the test implementation gets called at the end.

Once that was working, I realised that the filtering step could be applied to the beforeEach and afterEach calls too:

tinyTest.contexts.each((context) => {
    context.keyExists("beforeEachHandler") ? context.beforeEachHandler() : false
})

Becomes:

tinyTest.contexts
    .filter((context) => context.keyExists("beforeEachHandler"))
    .each((context) => context.beforeEachHandler())

And equivalently with afterEach:

tinyTest.contexts.reduce((reversedContexts, context) => reversedContexts.prepend(context), []).each((context) => {
    context.keyExists("afterEachHandler") ? context.afterEachHandler() : false
})

Becomes:

tinyTest.contexts
    .filter((context) => context.keyExists("afterEachHandler"))
    .reduce((reversedContexts, context) => reversedContexts.prepend(context), [])
    .each((context) => context.afterEachHandler())

The usage of ?: didn't sit well with me before, and I like this approach. Of course YMMV.

To copy and paste from last night's article: that's it. I'll add these to the source code:

You know what? I'm quite pleased that the aroundEach handling was so easily solved with just four chained method calls. It seems amazing to me for some reason. I don't mean my code is amazing: I mean the technique is.

Righto.

--
Adam