DEV Community

loading...

Dependency sandboxing in node.js with Jpex

Jack
Typescript and React nerd. I have a soft spot for architecture and unit testing
・4 min read

React recap

Okay so I've written about jpex a few times, particularly in relation to react

Essentially it allows you to do something like this:

import { useResolve } from 'react-jpex';

const useMyDep = () => {
  const dep = useResolve<IDep>();
  return dep.someValue;
};
Enter fullscreen mode Exit fullscreen mode

and this:

import { encase } from 'react-jpex';

const useMyDep = encase((dep: IDep) => () => {
  return dep.someValue;
})
Enter fullscreen mode Exit fullscreen mode

depending on your preferred flavour.

Jpex uses the service locator pattern to resolve and inject dependencies, plus it's super-powered by Typescript inference for a super slick experience. But the really really cool thing about it is you can test your react components with the provider component to stub your dependencies:

<Provider
  onMount={(jpex) => {
    jpex.constant<IDep>(mockValue);
    // everything rendered inside this provider will use the mock value
    // everything outside of the provider will use the "real" value
  }}
>
  <ComponentUnderTest>
</Provider>
Enter fullscreen mode Exit fullscreen mode

Using jpex with node.js

However, we're talking about node.js right now, not react. How does jpex work with node? Well at first glance it's pretty similar to the front end:

import jpex from 'jpex';

const getMyDep = () => {
  const dep = jpex.resolve<IDep>();
  return dep.someValue;
};
Enter fullscreen mode Exit fullscreen mode
import jpex from 'jpex';

const getMyDep = jpex.encase((dep: IDep) => () => {
  return dep.someValue;
});
Enter fullscreen mode Exit fullscreen mode

Easy right? The problem is that it's then quite hard to create a "sandboxed" environment. How do you call these functions with mocked values?

Option 1: mocking at the test level

it('returns some value', () => {
  jpex.constant<IDep>(mockValue);

  const result = getMyDep();

  expect(result).toBe(mockValue.someValue);
});
Enter fullscreen mode Exit fullscreen mode

This method can be problematic because you're registering a test mock on the global instance. It will then be used as the resolved value for IDep everywhere in the file, unless you register it again in the next test. This sort of leaky test is a bad idea and will almost definitely cause bugs.

Option 2: only using encase

it('returns some value', () => {
  const result = getMyDep.encased(mockValue)();

  expect(result).toBe(mockValue.someValue);
});
Enter fullscreen mode Exit fullscreen mode

encase actually exposes the factory function so you can manually pass in your dependencies, which means you can test it safely like this. This works well for some cases. But what if your function is called by another function?

const someOtherFn = () => {
  return getMyDep();
}
Enter fullscreen mode Exit fullscreen mode

Now you cannot test someOtherFn without getMyDep attempting to resolve its dependencies!

Option 3: the composite pattern

Another pattern for dependency injection is the composite pattern. Essentially your entire application is made up of factory functions that must compose at app start. In this case you'd be passing the jpex object through your composite chain like this:

export default (jpex) => {
  return {
    getMyDep: jpex.encase((dep: IDep) => () => dep.someValue),
  };
};
Enter fullscreen mode Exit fullscreen mode

I'm not keen on this myself, it kinda defeats the point of a service locator!

So if you can't actually invert the control of your dependencies, is jpex just useless in node applications? Yes... until now!

A more robust solution to DI and testing

I have just published a new library: @jpex-js/node. You use it like this:

import { resolve } from '@jpex-js/node';

const getMyDep = () => {
  const dep = resolve<IDep>();
  return dep.someValue;
};
Enter fullscreen mode Exit fullscreen mode
import { encase } from '@jpex-js/node';

const getMyDep = encase((dep: IDep) => () => {
  return dep.someValue;
});
Enter fullscreen mode Exit fullscreen mode

Looks familiar right? It's essentially the same syntax as jpex and react-jpex so far, and works exactly the same. The magic starts to happen when you want to sandbox and stub your dependencies...

The library exports a provide function. What this does is creates a new instance and then every resolve and encase call within is contextualised to this new instance. You can think of it as an equivalent to the <Provider> component in react-jpex.

If we attempt to write the same test as earier it might look like this:

import { provide } from '@jpex-js/node';

it('returns some value', () => {
  const result = provide((jpex) => {
    jpex.constant<IDep>(mockValue);

    return getMyDep();
  });

  expect(result).toBe(mockValue.someValue);
});
Enter fullscreen mode Exit fullscreen mode

Regardless of whether this function used resolve or encase, we're able to control the dependencies it receives!

One more thing

If the idea of a sandboxed DI context in which to run your tests seems cool, I should also point out that this supports asynchronous call stacks as well. Any promsies, callbacks, or timeouts, are kept within the same context:

provide(async (jpex) => {
  jpex.constant<IDep>(mockValue);

  await waitFor(200);

  setTimeout(() => {
    getMyDep(); // still retains the context
    done();
  }, 1000);
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

As the author of jpex I am definitely biased but I am a big proponant of making dependency injection a core part of javascript development, but also a slick developer experience. I've been using jpex in react applications for a few years now and I love it. And now with this library, we should be able to bring the same patterns and ease of testing to node applications as well.

Behind the scenes we're using node's new async_hooks module and the AsyncLocalStorage class. It's a really powerful concept for creating and managing contexts and there is so much potential for awesome uses of it!

Discussion (0)