A good way to write our own Node-RED node for the testability

eye-catchJavaScript/TypeScript

I have used Node-RED for more than 3 years for my working project. I came up with a good idea to write our own node for the testability, so I want to share it with you.

The target reader is the following.

  • who has multiple flows that have the same logic
  • who has a user defined node that could return null or undefined
  • who thinks that writing test for Node-RED node is not readable

For the first one, we need to write the same test in this case but we don’t want to repeat the same thing.

For the second, the way that the official site explains can’t cover all cases. If the logic is in our own node and it returns null or undefined, it’s impossible to write the test because output node expects that it receives something from the target node.

Let’s solve the problems.

Sponsored links

The working environment

Let me explain my environment. There are several flows that have exactly the same logic for some paths. Each flow has a different interface for the input but the output interfaces are the same for all flows. It offers OPC UA, MQTT, and MTConnect interfaces.

node-red-env

The data conversion process is different because the input data is different for each input interface. However, some function nodes have the same logic in the different flows. Therefore, we need to write similar Node-RED flow tests for each flow.

My team has written a lot of similar tests so far but it is hard to write some test cases.

  • The order that the output node receives can be different from the order of the test data injection
  • It depends on whether async funcion is used or not on the path
  • It depends on the number of nodes on the path
  • Timer related function is used in the path
  • It’s hard to trigger the timer function when it’s desired to be fired even if it is stubbed
  • Error case can’t be tested
  • Unsent conditions can’t be tested

In addition to this list, it’s harder to write test Node-RED flow test than a standard unit test. The meaning of “hard” here is long or cumbersome because it requires additional settings to make the Node-RED flow work.

I wanted to solve these problems.

The logic separation from Node-RED node

The main problem with the test is that some stuff is out of control. If we stub setImmediate, Node-RED flow doesn’t work. It doesn’t send a message to the wired nodes. If it is not possible to write the desired test on the flow test, we need to create another test sweet.

The basic implementation of a user-defined node looks like the following.

import * as NodeRed from "node-red";

export = (RED: NodeRed.NodeAPI): void => {
    RED.nodes.registerType("node-name", function (this: NodeRed.Node, properties: NodeRed.NodeDef) {
        RED.nodes.createNode(this, properties);

        // preparation steps here

        this.on("input", (msg, send, done) => {
            // node specific process
        });

        this.on("close", (done: () => void) => {
            // release
        });
    });
};

If you don’t know how to implement a user-defined node, check the following post.

The input event is triggered when the node receives a message. We can write the node test with node-red-node-test-helper but let’s do it in a different way.

What do you think if we have the following structure?

import * as NodeRed from "node-red";
import { MyClass } from "./MyClass";

export = (RED: NodeRed.NodeAPI): void => {
    RED.nodes.registerType("node-name", function (this: NodeRed.Node, properties: NodeRed.NodeDef) {
        RED.nodes.createNode(this, properties);

        const instance = new MyClass();

        this.on("input", (msg, send, done) => {
            try{
                const result = instance.execute(msg);
                send(result);
                done();
            } catch (e) {
                done(e);
            }
        });

        this.on("close", (done: () => void) => {
            instance.release();
            done();
        });
    });
};

In this case, we can easily write tests for MyClass without any interaction with the Node-RED world.

My suggestion would be to define the main logic in a different class. Don’t write the logic directly in the node.Then, it’s much easier to write the tests. We can do whatever we want.

Most test cases can be written in the defined class but some logic is still written in the node. If you want to write some test for the logic written in the node, you should pass the instance from a function that can be stubbed. Using factory method is one of the ways.

export class MyClass {
    public static create(): MyClass{
        return new MyClass();
    }
    private constructor() {}
    ...
}

If you define a factory method, you can stub it by sinon.

const stub = MyClass.create();
sinon.stub(MyClass, "create").return(stub);

// You can define a fake behavior in a test case
sinon.stub(stub, "execute").throws("test-error");

Do you want to write tests for a function node

Node-RED offers a function node where we can define whatever we want. It is useful when we want to write simple code there. What if we want to write a bit of complicated logic there? In this case, we want to write tests against the logic. It’s possible to write tests only for the function node but it is not as readable as a standard unit test.

I think there are two ways to solve the problem.

  • Define the target function in our code and export it through functionGlobalContext property
  • Define the target function in a user-defined node

As you might know, we can export our own module to the Node-RED world. I want you to check the official page to know the details but the exported module can be used in the following way.

const func = global.get("myFunc");
const msg.payload = func();
return msg;

But this way has the following restrictions.

  • send function can’t be used in the function
  • Context can’t be used

So I suggest using the second way to have no restrictions.

Template node for the pre-defined function

I implemented a template for it. You can download it from my repository and add your own function.

GitHub - yuto-yuto/node-red-contrib-pre-defined-func
Contribute to yuto-yuto/node-red-contrib-pre-defined-func development by creating an account on GitHub.

Node definition

The implementation looks like this.

import * as NodeRed from "node-red";
import { PreDefinedFuncProperties } from "./NodeProperties";
import { PreDefinedFunctions, selectFunc } from "./StoredFunctions/FunctionSelector";

export = (RED: NodeRed.NodeAPI): void => {
    RED.nodes.registerType("pre-defined-func", function (this: NodeRed.Node, properties: PreDefinedFuncProperties) {
        RED.nodes.createNode(this, properties);

        const funcName = properties.funcName as PreDefinedFunctions;
        const strategy = selectFunc(this, funcName);

        this.on("input", (msg, send, done) => {
            const result = strategy.execute(msg, send);
            if (result !== undefined && result !== null) {
                msg = { ...msg, ...result };
                send(msg);
            }
            done();
        });

        this.on("close", (done: () => void) => {
            strategy.release();
            done();
        });
    });
};

A user needs to select one of the functions on the node screen. This implementation applies a strategy pattern. By changing the function name, the actual behavior changes.

Function Selector implementation

selectFunc returns the abstract class depending on the function name.

export type PreDefinedFunctions = "Example";

export function selectFunc(node: NodeRed.Node, funcName: PreDefinedFunctions): FunctionStrategy<NodeRed.NodeMessage> {
    switch (funcName) {
        case "Example": return new ExampleFunction(node);
        default: {
            // if there is a missing definition, the transpiler tells us the error
            const check: never = funcName;
            throw new Error(check);
        }
    }
}

When adding a new function class, you need to do the following.

  1. Add the function name to PreDefinedFunctions type
  2. Add a switch-case for it and return the function class

Abstract class FunctionStrategy

The abstract class implementation looks like this.

import * as NodeRed from "node-red";

export abstract class FunctionStrategy<T extends NodeRed.NodeMessage> {
    constructor(protected node: NodeRed.Node) { }

    protected get flow(): NodeRed.NodeContextData {
        return this.node.context().flow;
    }

    protected get global(): NodeRed.NodeContextData {
        return this.node.context().global;
    }

    public execute(msg: T, send: any): NodeRed.NodeMessageInFlow | null | undefined {
        try {
            return this.executeInternal(msg, send);
        } catch (e) {
            if (e instanceof Error) {
                // Replace this console with your logger if necessary
                console.error(`${e.message}\n${e.stack}`);
            }
            return null;
        }
    }

    protected abstract executeInternal(msg: T, send: any): any;

    public release(): void {
        // Do nothing in abstract class
    }
}

In a function node, flow and global context can be used. Therefore, there are getters for them. However, node-level context doesn’t exist there because it can be done by storing data in a variable. If the release process is necessary, add the process in the release method.

Example implementation

I implemented an example. This node returns the max value if it receives three messages with the desired topic value.

export class ExampleFunction extends FunctionStrategy<ExamplePayload> {
    private firstValue?: number;
    private secondValue?: number;
    private thirdValue?: number;

    protected executeInternal(msg: ExamplePayload, send: any): any {
        // Implement you logic here
        if (msg.topic === "First") {
            this.firstValue = msg.payload;
        }

        if (msg.topic === "Second") {
            this.secondValue = msg.payload;
        }

        if (msg.topic === "Third") {
            this.thirdValue = msg.payload;
        }

        if (msg.topic === "error") {
            throw new Error("Error is handled in the base class.");
        }

        if (msg.topic === "multi") {
            // multi payloads can be sent in this way.
            send([[
                { payload: "first" },
                { payload: "second" },
            ]]);
            // Don't forget to return here.
            return null;
        }

        // send data only all the three data come to this node
        if (
            this.firstValue !== undefined &&
            this.secondValue !== undefined &&
            this.thirdValue !== undefined
        ) {
            const max = Math.max(this.firstValue, this.secondValue, this.thirdValue);

            // Store the info in flow level. This info can be accessed from the other nodes on a flow.
            this.flow.set("Example-Max", max);
            msg.payload = max;

            // Returned value will be sent in StoredFuncNode.
            return msg;
        }

        console.info("The message cannot be sent yet.");

        // otherwise, returns null or undefined. Null and undefined won't be sent.
        return undefined;
    }
}

If you implement this on a flow, you need to put a join node and function node. It’s easy to implement but when it comes to writing tests for the logic, it becomes hard.

If you don’t know how to write flow tests, check this post.

The flow test can’t test for the error cases because if it throws an error or returns null/undefined, the message is not propagated to the wired nodes. This implementation solves the problem.

Conclusion

Node-RED is useful if we want to implement something in a short time but the testability is not so good. We somehow guarantee that the flow works as expected. If you haven’t written any tests for the logic on the flow, I suggest you to try this way.

Comments

Copied title and URL