[RFC] Typesafe module mocking based on package.json standards #26640
Replies: 2 comments 2 replies
-
I am impressed by the effort put into this RFC. I'd like to echo @kasperpeulen's sentiment and contribute some best practices from Nuxt for module authoring, particularly regarding auto-import facilitation or aliasing for vite/webpack imports, which align closely with his objectives. Best Practices
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
} |
Beta Was this translation helpful? Give feedback.
-
I really LOVE this particular approach of vitest:
So PLEASE let this be possible with Storybook too. The nice thing about it is that it keeps everything related to the story local inside the story file itself, instead of having to litter your project and exports with mock modules. Writing separate modules for everything we want to mock is just so painful. Code generation might make this easier, but it pushes an extra build step complexity and yet another potential failure point to the user, so I honestly don't feel excited about it. It also doesn't solve the issue of having our project be polluted by mock modules. |
Beta Was this translation helpful? Give feedback.
-
Status: implemented
🖼️ Overview
Outcome
We want users to be able to change (mock out) the implementation of a module when it runs inside of storybook.
Purpose
fs
andpath
.💡 Proposed Solution
The proposal is to use package.json
imports
as a standard based way to mock out modules in storybook. We feel that the time is right to at least giving module mocking without any magic a fair try, before we consider other, more magical, approaches.The way that Jest (later adopted by Vitest) solves module mocking, evolved in a time where no ESM standards existed at all. The API used here requires very little boilerplate, which is nice, but the amount of magic needed also has some drawbacks:
jest.mock/vi.mock
statements are rewritten to be hoisted at the top and can not easily consume variables declared in the file.Those problems don't exist in a fully explicit standard based mocking solution, which gives us extra motivation to explore this angle.
Package.json
imports
The
"imports"
field inpackage.json
allows you to configure import aliases that only apply to imports from within the package itself.Entries in the
"imports"
field must always start with#
to ensure they are disambiguated from external package specifiers.Absolute imports
This feature allows for writing absolute imports in Node natively. There is no bundler or compiler magic needed to get it to work. Just add this to your package.json:
And now you can import the path:
{root}/components/foo.js
import { foo } from '#components/foo'
import { foo } from '#components/foo.js'
if you use ESM natively in Node.Other absolute import conventions have been popularised such as importing
@/components/foo
and manually configuring this in TS and in your bundler config. However, now TS 5.4 supports this package.json feature and has autocompletion support for it, it seems that Next.js wants to move to package.json imports as well for absolute import support:https://x.com/sebmarkbage/status/1765828741500981475?s=20
Kennt C. Dodds popularized absolute imports like this in the Remix community already for some time:
https://github.com/epicweb-dev/epic-stack/blob/main/docs/decisions/031-imports.md
Potentially (and hopefully), all frameworks/stacks are gonna gradually converge to this native way of absolute imports.
Conditional absolute imports
One exciting opportunity for Storybook here is that package.json
imports
can be made conditional in the same way as package.jsonexports
:https://nodejs.org/api/packages.html#conditional-exports
The built-in Node conditions are
"import"
,"require"
,"node"
,"node-addons"
and"default"
, however, any other string can be used and triggered by running node with -C options:Will resolve the conditional import to
storybook
entry, if it exists.Other commonly accepted package conditions (for example, implemented by bundlers) are:
"types"
- can be used by typing systems to resolve the typing file for the given export."browser"
- any web browser environment."development"
- can be used to define a development-only environment entry point, for example to provide additional debugging context such as better error messages when running in a development mode."production"
- can be used to define a production environment entry point.Other other runtimes, platform-specific condition are:
deno
,react-native
,netlify
,electron
,bun
,react-server
,fastly
To name a few.
Implement
storybook
conditions to our buildersStorybook of course doesn’t run on node, but the web (and its tooling) has embraced package.json conventions as the way to resolve imports. it would be natural to implement module mocking in a way that is officially supported by package.json.
For example, if the users add the following to package.json:
Would mean that whenever the user imports:
import { getAllTodos } from '#api/todo'
It will resolve when running in storybook to:
{root}/api/todo.mock.ts
if that file exists and otherwise fallback{root}/api/todo.ts
It is quite unfortunate for our use case that the "spec" doesn't allow for a one-liner like this:
Actually
webpack
and TS do allow this, but they implement the spec "incorrectly".Node does allow a fallback array, when validation fails, but it won't check if the file exists, as that would be too slow. All validation rules are implemented statically, so that it can be super fast.
Maybe at some point, we should make a proposal to change this spec with the module mocking use case in mind.
My feeling is that performance is actually not that big of issue if the validation array is used for conditions that only resolve for development or testing use cases. Even more so as often a bundler is used in those cases. And for the web this is almost exclusively true, as even HTTP/2 is not fast enough to outcompete a bundler.
Possibly the spec should be expanded with that in mind? Maybe node could allow a feature flag for those use cases? A flag that test runners could apply etc.
Advantages of using a standard
This feature allows use to module mocking in storybook completely based on standards.
The advantage is that by using the
"imports"
field, we don’t have to do anything special forvite
orwebpack
, they just understand the syntax.Similarly,
Eslint
orTypeScript
doesn’t have to configured specially to make sure they understand this syntax. Since TS 5.4, package.jsonimports
are also auto-completed!https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/#auto-import-support-for-subpath-imports
Comparison with vitest/jest manual mocking
This way of module mocking is comparable with how vitest and jest look for mock files in a adjacent
__mocks__
directory:https://jestjs.io/docs/manual-mocks
In vitest/jest, when you want to use the mock instead of the real implementation, you still need to explicitly call
jest.mock('./moduleName')
orvi.mock('./moduleName')
in your test file.With package.json imports, the mocked module will always be used when running storybook. To make sure the user can opt-out of mocking the module, we can advice mock file content similar as this:
This means that by default the unmocked version will be used in storybook as well, it will only be spied on by default, so that you can assert on it in your play function:
When you want to change the mock implementation for a certain test, you can use:
Before each story loads, we restore all mocks to the default implementation (we already do this, for spies attached to args), so that the original function is restored.
If you do want a default mock implementation for all stories you can do so in your preview:
One other advantage of defining your mock implementation in preview/meta or the story itself, is that you have access to the story args:
Which would integrate nicely with another proposal (RFC coming) where $-prefixed-args are treated as non-component args. So won't be automatically applied to the component.
Automatic boilerplate generation
With this pattern, the actual
api/todos.mock.ts
file can be code-generated automatically. ES exports needs to be statically defined. But because they are statically defined we can quite easily with AST parsing find out the exports of the module “to-mock”. So that we can code generate files like these:One advantage of code-generation over the more dynamic approach that vitest and jest use, is that TS will work out of the box.
The pattern the user would need is always importing the actual “mocked” file in their story file:
In some sense, this code generation, makes sure that the story file is a bit cleaner as if we would adopt a more magical approach such as vitest does:
Of course, Vitest approach has also a lot of advantages, but I’m eager to at least start with a zero magic approach, just using standards, and iterate on that to make that as easy as possible to write.
What to do with mocking external modules?
There are a couple of options:
next
framework we can automatically mock some files that are usingAsyncLocalStorage
under the hood (such asnext/headers
)prisma
in autils/db.ts
and mock that file out, instead ofprisma
bcryptjs
by changing it to an#bcryptjs
import that is made conditional in package.json. This pattern forces you though to write this external dep everywhere you use it as#bcryptjs
.Beta Was this translation helpful? Give feedback.
All reactions