DEV Community

Dmitry Zakharov
Dmitry Zakharov

Posted on

Testing in ReScript

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.

Wallaby test story

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
}
Enter fullscreen mode Exit fullscreen mode
// ModuleName.resi
type t

let fromBscFlag: string => option<t>

module TestData: {
  let make: string => t
}
Enter fullscreen mode Exit fullscreen mode
// ModuleName_test.res
test("fromBscFlag works with Belt", t => {
  t->Assert.deepEqual(
    ModuleName.fromBscFlag("-open Belt"),
    Some(ModuleName.TestData.make("Belt")),
    (),
  )
})
Enter fullscreen mode Exit fullscreen mode

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.

File structure with tests placed close to the tested code

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.",
            },
          ],
        },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
// A.res
include AFactory.Make({
  let doSomething = B.doSomething
})
Enter fullscreen mode Exit fullscreen mode
// 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, ())
})
Enter fullscreen mode Exit fullscreen mode

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(.)
Enter fullscreen mode Exit fullscreen mode

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:

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)