Dependency sandboxing in node.js with Jpex
Jack
Posted on July 6, 2021
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;
};
and this:
import { encase } from 'react-jpex';
const useMyDep = encase((dep: IDep) => () => {
return dep.someValue;
})
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>
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;
};
import jpex from 'jpex';
const getMyDep = jpex.encase((dep: IDep) => () => {
return dep.someValue;
});
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);
});
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);
});
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();
}
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),
};
};
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;
};
import { encase } from '@jpex-js/node';
const getMyDep = encase((dep: IDep) => () => {
return dep.someValue;
});
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);
});
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);
});
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 theAsyncLocalStorage
class. It's a really powerful concept for creating and managing contexts and there is so much potential for awesome uses of it!
Posted on July 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.