8

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.

EDIT: 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.

philosopher
  • 1,079
  • 2
  • 16
  • 29

2 Answers2

4

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.

kkkkkkk
  • 7,628
  • 2
  • 18
  • 31
  • Thanks for the response. An interesting approach to test it for sure but this is repeating a lot of the code from the route in a different location. If someone changes the code in the route, these tests will continue to pass unless they copy paste the new code into the tests as well. – philosopher Feb 06 '20 at 07:52
  • Do you mean if `onCloseHandler` changes, tests for `updatesHandler` still pass? – kkkkkkk Feb 06 '20 at 08:35
  • 1
    Ah okay, sorry, I just read through it again. You are suggesting to refactor my code such that the my route is divided into multiple functions and then export those functions so that they can be tested individually. This seems to be a valid approach but it would imply I am refactoring them into multiple functions just for testing purposes. Wouldn't that be considered bad practise? For example, when I am testing the other routes with supertest, none of my routes need to be split up into multiple functions. – philosopher Feb 06 '20 at 09:25
  • It's not at all a bad practice, but rather how clean code look like. Coding in this style ensures that your code can partly be self-documenting if functions are named appropriately. Furthermore, each function will become smaller, have fewer global references and less responsibility, which should make them easier to reason about. – kkkkkkk Feb 06 '20 at 10:50
1

Have a look at the tests for the express-sse library. They spin up the server on a port, then create an instance of EventSource and connect it to the SSE end-point on that running server.

Something like this:

describe("GET /my-events", () => {

  let events
  let server
  beforeEach(function (done) {
    events = new EventEmitter()
    const app = createMyApp(events)
    server = app.listen(3000, done)
  })

  afterEach(function (done) {
    server.close(done)
  })

  it('should send events', done => {
    const es = new EventSource('http://localhost:3000/my-events')
    
    events.emit('test', 'test message')
    es.onmessage = e => {
      assertThat(e.data, equalTo('test message'))
      es.close()
      done()
    }
  })
})

That seems like the right way to test it, to me.

Matt Wynne
  • 143
  • 1
  • 8