Hello, I’m Dmitry Zakharov, a Frontend-engineer at Carla and author of rescript-struct. After using ReScript for two years, of which 9 months are in production, I’ve gotten some insights about testing I wanted to share. I use ReScript not only for FE but for different areas, so if you’re a BE engineer or create a CLI tool, this article will also be useful for you.
Testing in ReScript is not much different from testing JavaScript. That’s because we don’t test ReScript directly, but the code it generates. But the difference is that thanks to the great type-safety that ReScript provides, it’s almost unnecessary to test the connection between modules inside of your system. My experience with ReScript has shown that having unit tests for domain/business logic and a few end-to-end (e2e) tests to check that the whole system works correctly when modules are combined - is more than enough.
Unit tests
Regarding unit testing, the library you use doesn't really matter. Choose any that works for you. My personal preference is to use bindings for AVA.
Another popular option is to use bindings for Jest. However, in my opinion, Jest is a monstrous combine that is slow, has a hacky mock system, bad ESM support, and purely tries to mimic DOM API with JSDOM. AVA literally doesn't have any of these. Also, the available Jest bindings don't allow doing multiple assertions per test, which is often useful to me.
Besides bindings for JavaScript libraries, there is rescript-test - a lightweight test framework written in ReScript for ReScript. I have heard that some people like it, but for me, it lacks coverage output and Wallaby support.
E2E
Even though I claim that integration tests are unnecessary, we still need to ensure that the application works correctly when all the modules are combined. For this purpose, I like having a few E2E tests that imitate an end user of my application and ensure that it works as intended.
For FE, it’s usually Cypress or Playwright; for BE, it’s to run a server and start sending requests; for CLI, I like the tool called execa.
The idea is to imitate a real user. And using ReScript for this kind of test is good but not mandatory. You can have Cypress tests in Js, Selenium, Postman, or other external tools.
Tip 1: Wallaby
Up till now, I have mostly shown my worldview. But now, I would like to share with you some tips. And the first one is Wallaby.
It is a JavaScript test runner that provides real-time code quality feedback. Even though in ReScript we don't get all the benefits, I still find it useful when it comes to diving into JavaScript to debug some failing tests. The stories feature is particularly helpful, where you can visually see how the generated code is executed.
It helps to have tests always running and brings enjoyment to the process of working with them.
Tip 2: Interfaces and opaque types
Opaque types in ReScript are types whose implementation is hidden from other parts of the program, allowing for information hiding, modularity, type safety, and improved performance. They provide a clear interface for interacting with the type and can help ensure that the internal state of the type is consistent and well-formed. Shortly speaking, they are wonderful!
Although when we write tests, we want to be able to assert that an opaque entity has an exact value. But it’s impossible to create the exact value for assertion since the implementation is hidden behind an interface.
To workaround the problem, I’ve started creating TestData
modules with functions to create a value of the opaque type directly. And have a convention that it’s only allowed for use inside of the test files. Here’s an example:
// ModuleName.res
type t = string
let fromBscFlag = bscFlag => {
// Some logic
}
module TestData = {
let make = moduleName => moduleName
}
// ModuleName.resi
type t
let fromBscFlag: string => option<t>
module TestData: {
let make: string => t
}
// ModuleName_test.res
test("fromBscFlag works with Belt", t => {
t->Assert.deepEqual(
ModuleName.fromBscFlag("-open Belt"),
Some(ModuleName.TestData.make("Belt")),
(),
)
})
I’m very satisfied with the approach. Although if you develop a library and don’t control the end user of your code, it’s not a good idea to expose innards like this.
Tip 3: Place tests close to the tested code
Another tip that won’t work for libraries while definitely worthy for applications. And it’s to place test files close to the code they are testing. Which can improve organization, maintainability, and convenience.
That’s pretty common for Js, but in ReScript, the most common approach is to connect a testing library via the bs-dev-dependencies
and put tests in the __tests__
directory, marking it as dev
in the bsconfig.json
.
I think that the value of placing tests close to the code is much higher than using bs-dev-dependencies
, so I moved a testing library to the bs-dependencies
and removed the __tests__
directory.
If you are afraid of test code leaking into the application. You can use eslint to prevent this:
export default [
{
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["*_test.bs.mjs"],
message: "It's not allowed to import test modules.",
},
],
},
],
},
},
];
Tip 4: Mocking
Especially when talking about unit testing, we often have external dependencies that should be replaced during a test. And me saying that Jest’s mocking is a bad thing (the same goes for Vitest) might sound a bit controversial.
One reason is that mocking in Jest heavily relies on JavaScript’s module system, which differs from ReScript’s. Also, the insanely hacky implementation and terrible DX make it even worse. And the main reason is that DI (Dependency Injection) solves the problem much better.
Implementation depends on your architecture and will vary from project to project. I’ll share some examples of how I do it myself.
I find that in most cases, DI can be done by simply passing dependencies as function arguments without any abstractions. And then return an implementation which is usually a single function:
// Lint.res
let make = (
// Dependencies
~loadBsConfig,
~loadSourceDirs
) => {
// Implementation of the lint logic
(. ~config) => {
// Some code
}
}
The example is taken from my rescript-stdlib-vendorer project, which uses the approach as the core of the architecture. I recommend you take a look at the source code if you want to see a bigger picture.
But sometimes, implementation needs to be a whole module. And for this, we can use ReScript functors (functions that create modules).
The idea is the following: when you are working on module A
, instead of calling B.doSomething
directly, you have an AFactory.Make
functor that accepts doSomething
as an argument and returns the A
module. This way, you can create the A
module with different dependencies for testing.
// AFactory.res
module Make = (T: {
let doSomething: () => ()
},
) => {
let call = () => {
T.doSomething()
true
}
}
// A.res
include AFactory.Make({
let doSomething = B.doSomething
})
// A_test.res
test("Calls doSomething once", t => {
let doSomethingCallsCountRef = ref(0)
module A = AFactory.Make({
let doSomething = () => {
doSomethingCallsCountRef := doSomethingCallsCountRef.contents + 1
()
}
})
A.call()
t->Assert.is(doSomethingCallsCountRef.contents, 1, ())
})
The approach with functor has a few problems, usually not critical ones, though.
One problem is that it suggests creating A.res
, which is publicly available from other application parts. It’s usually fine for FE apps, but for developing BE and CLIs, I prefer to follow the hexagonal architecture by creating the implementation inside the Main.res
and passing it to other modules via function arguments. Once again, an example from rescript-stdlib-vendorer:
// Main.res
let runCli = RunCli.make(
~runLintCommand=RunLintCommand.make(
~lint=Lint.make(
~loadBsConfig=LoadBsConfig.make(),
~loadSourceDirs=LoadSourceDirs.make()
),
),
~runHelpCommand=RunHelpCommand.make(),
~runHelpLintCommand=RunHelpLintCommand.make(),
)
runCli(.)
Another problem with ReScript functors is that the created module is not tree-shakable. But if it’s really a problem, you probably already know how to work around it.
Tip 5: Test bindings
Bindings with JavaScript is the most dangerous part of any application - the bridge with an unsafe and unpredictable world. And it’s definitely a bad idea to trust yourself that you’ve done it correctly. Especially considering the fact that it might become outdated during the next release.
Honestly, I’m guilty myself of neglecting binding tests. So I can’t give you any advice on how to handle it in a good way. But at least I want you to be more careful and consider writing tests for bindings when you see that it might become a problem.
Tip 6: Coverage
Lastly, I want to share a small personal thing. I really like numbers and as well as seeing the result of my work in numbers. One of the ways to achieve this is to configure a test coverage analytics tool like Codecov in your CI. And although being over-concentrated on coverage is not a healthy and useful thing, I really noticed that it makes writing tests more exciting. It probably won’t work for everyone, but at least I recommend trying it out.
Project examples
You can see how I follow the described ideas in my open-source projects. If you're interested, I recommend checking out the most model ones:
- rescript-struct - a good example of testing a library
- rescript-stdlib-vendorer - a good example of testing an application
Discussion is encouraged
I realize that I’ve mentioned quite a few contradictory things without going into detail. If you have any questions, suggestions or want to argue, feel free to speak up in the comment section or ping me on Twitter.
Top comments (0)