hckr.fyi // thoughts

Unit Testing Bot Framework Middleware with TestAdapter, Mocha, and...

by Michael Szul on

Unit Testing Bot Framework chatbots is probably one of the least documented parts of the Bot Framework. Luckily, the source code is well-documented, and you can usually dig around and find what you need to get started.

A lot of that starts with the TestAdapter class, which is a special adapter used for returning a TestFlow class when functions such as test(), send(), or assertReply() are called. In practice, you create a TestAdapter in your test code, instantiating any functionality or bot logic that you need, and then you close out your test using one of the functions mentioned above. The send() function allows you to send messages to the bot, while the assertReply() tests the returning message. The test() method, meanwhile is essentially a combination of the two.

Recently, I started writing out the unit tests for the the Bot Builder Community Node.JS library, and some of our libraries contain middleware components, such as the spellcheck middleware. How do we effectively test middleware that calls out to an external service? With the rewire package mentioned in a previous post, and some simple mocking.

Start with a few imports; In this case, we need the test adapter, rewire, and our actual spellcheck file:

const { TestAdapter } = require("botbuilder");
    const rewire = require("rewire");
    const spellchecker = rewire("../lib/spellcheck")
    

If you don't actually have rewire installed, be sure to install it: npm install rewire --save-dev.

Wait! What is that third line? Right. We're not importing our spellchecker code with require(), but instead with rewire(). The rewire() package will import our spellchecker code with __set__() and __get__() methods added.

Now, the spellchecker middleware code is pretty simple. The onTurn() method looks like the following:

public async onTurn(context: TurnContext, next: () => Promise<void>): Promise<void> {
        if (context.activity.type === ActivityTypes.Message) {
            this.text = context.activity.text;
            const url: string = getUrl(this.text);
            try {
                const re: WebRequest.Response<string> = await getWebRequest(url, this.key);
                const obj: any = JSON.parse(re.content);
                if (obj.flaggedTokens && obj.flaggedTokens.length > 0) {
                    try {
                        if (obj.flaggedTokens[0].suggestions[0].suggestion) {
                            const suggestion: any = obj.flaggedTokens[0].suggestions[0].suggestion;
                            const token: any = obj.flaggedTokens[0].token;
                            context.turnState.set('token', token);
                            context.turnState.set('suggestion', suggestion);
                        }
                    } catch (error) {
                        throw new Error(error);
                    }
                }
            } catch (e) {
                throw new Error(`Failed to process spellcheck on ${context.activity.text}. Error: ${e}`);
            }
        }
        await next();
    }
    

I won't step through this, but basically the onTurn() method is what gets called each turn, and passed the context of the conversation. In this method, notice that we make a web request to a URL that requires a key. This is the URL for Azure's spellchecker service, and the key required to process information. When we receive the result, that result is parsed and added to the turn state, so the user can access it in their bot code.

Since we're unit testing the middleware, we don't want to make an actual call to the Azure service, and even if we did, we'd be exposing our keys to the GitHub repo, or be required to put them in an .env file to run. Anyone who wants to run the tests would then have to have an account, and set this up. That's not a unit test. That's an integration test.

We need to mock the web request, and we're going to do it using rewire.

Put the following code below your import statements:

const mock = async function getWebRequest(url, string) {
        return Promise.resolve({
            content: JSON.stringify({
                _type: "SpellCheck",
                flaggedTokens: [{
                    offset: 0,
                    token: "hellow",
                    type: "UnknownToken",
                    suggestions: [{
                        suggestion: "hello",
                        score: 0.875
                    }]
                }]
            })
        });
    }
    
    spellchecker.__set__("getWebRequest", mock);
    

The spellchecker middleware has a non-exported function called getWebRequest(), which actually calls the spellchecker service. We create our own function called the same thing that returns the same data, doesn't actually connect to a service. You can see that all we're doing is returning a resolved Promise that has a content object, which contains a stringified result normally received from the spellchecker. We've hard-coded the token and the suggestion since we only care about how the middleware works, not the spellchecker service itself.

The next thing we do is call the __set__() method that rewire added, and we replace the getWebRequest() function with the new one we created.

Now let's write our test:

describe('Spellcheck middleware tests', function () {
        this.timeout(5000);
        it('should spellcheck a message', async () => {
            const adapter = new TestAdapter(async (context) => {
                await context.sendActivity(context.turnState.get('suggestion'));
            });
            adapter.use(new spellchecker.SpellCheck("not a real key"));
            await adapter.test('hellow', 'hello');
        });
    });
    

We're using Mocha as our test framework here, so we set up our describe() and it() methods. Inside of our it() method, we create a TestAdapter. When this adapter is interacted with, the only thing it does is return something from the turn context: the data that the spellchecker attaches.

In the next line, we use() the middleware. Notice that I'm sending fake data into the spellchecker as the key.

Lastly, we await one test. In this case, it's:

await adapter.test('hellow', 'hello');
    

What this does is send a single message to the chatbot with a mispelled word "hellow" that gets run through the middleware. The middleware (when not mocked) should run that through the spellchecker and return the appropriate suggestion in the context.turnState collection with a "suggestion" key. That suggestion should be "hello" and our test() method is checking that.

Now, since we mocked the web request, we know that the middleware will always turn "hello" so clearly we're not testing the spellchecker. What we're testing is that the middleware appropriately parses the expected JSON structure, and assigns the correct data to the right keys in the turn state. This lets us test the functionality of our middleware without the connection to the service.