Search code examples
node.jsserver-sent-eventssupertest

How to test a Server Sent Events (SSE) route in NodeJS?


I have a Server Sent Events route on my NodeJS app that clients can subscribe to for getting real-time updates from the server. It looks like follows:

router.get('/updates', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    })

    const triggered = (info) => {
        res.write(`\ndata: ${JSON.stringify(info)}\n\n`)
    }

    eventEmitter.addListener(constants.events.TRIGGERED, triggered)

    req.on('close', () => {
        eventEmitter.removeListener(constants.events.TRIGGERED, triggered)
    })
})

Testing a traditional route using supertest is simple enough in node:

test('Should get and render view', async() => {
    const res = await request(app)
        .get('/')
        .expect(200)
    
    expect(res.text).not.toBeUndefined()
})

However, this does not work when testing a SSE route.

Does anyone have any ideas on how to test a SSE route with Node? It doesn't necessarily have to be tested with supertest. Just looking for ideas on how to test it, supertest or otherwise.

I have an idea about how to integration test this. Basically, one would have to spin up a server before the test, subscribe to it during the test and close it after the test. However, it doesn't work as expected in Jest when I use beforeEach() and afterEach() to spin up a server.


Solution

  • I would mock/fake everything used by the endpoint, and check if the endpoint executes in the right order with the correct variables. First, I would declare trigger function and close event callback outside of the endpoint so that I could test them directly. Second, I would eliminate all global references in all functions in favor of function parameters:

    let triggered = (res) => (info) => {
        res.write(`\ndata: ${JSON.stringify(info)}\n\n`);
    }
    
    let onCloseHandler = (eventEmitter, constants, triggered, res) => () => {
        eventEmitter.removeListener(constants.events.TRIGGERED, triggered(res));
    }
    
    let updatesHandler = (eventEmitter, constants, triggered) => (req, res) => {
        res.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive'
        });
    
        eventEmitter.addListener(constants.events.TRIGGERED, triggered(res));
    
        req.on('close', onCloseHandler(eventEmitter, constants, triggered, res));
    };
    
    router.get('/updates', updatesHandler(eventEmitter, constants, triggered));
    

    With this code, the test cases would be like:

    test("triggered", () => {
        let res;
    
        beforeEach(() => {
            res = generateFakeRespone();
        });
    
        it("should execute res.write with the correct variable", () => {
            trigger(res)("whatever");
    
            expect(res.write).to.have.been.called.once;
            expect(res.write).to.have.been.called.with(`\ndata: ${JSON.stringify("whatever")}\n\n`);
        });
    });
    
    
    test("onCloseHandler", () => {
        let res;
        let eventEmitter;
        let constants;
        let triggered;
    
        beforeEach(() => {
            res = Math.random();
            eventEmitter = generateFakeEventEmitter();
            constants = generateFakeConstants();
            triggered = generateFakeTriggered();
        });
    
        it("should execute eventEmitter.removeListener", () => {
            onCloseHandler(eventEmitter, constants, triggered, res);
    
            expect(eventEmitter.removeListener).to.have.been.called.once;
            expect(eventEmitter.removeListener).to.have.been.called.with(/*...*/)
        });
    });
    
    test("updatesHandler", () => {
        beforeEach(() => {
            req = generateFakeRequest();
            res = generateFakeRespone();
            eventEmitter = generateFakeEventEmitter();
            constants = generateFakeConstants();
            triggered = generateFakeTriggered();
        });
    
        it("should execute res.writeHead", () => {
            updatesHandler(eventEmitter, constants, triggered)(req, res);
    
            expect(res.writeHead).to.have.been.called.once;
            expect(res.writeHead).to.have.been.called.with(/*...*/)
        });
    
        it("should execute req.on", () => {
            //...
        });
    
        // more tests ...
    });
    

    With this style of coding and testing, you have the ability to make very detailed unit test. The downside is that it take much more effort to test everything properly.