Skip to content

JSX testing explained

Architecture approach

The plugin-based approach for sourcing JSX components that Whitebox uses makes it a powerful system, but with it comes some complexity. If you're looking to get JSX tests up and running quickly, check the Testing JSX code section of the plugin guide.

The static build of the frontend React app is what constitutes the Whitebox's kernel. All the features that it provides when launched (e.g. map), are a part of some plugin's code that augments the application through its provided JSX.

JSX code is provided outside the static build, using vite-plugin-federation, which are built and server by the backend server. This adds a layer of complexity to the testing process, as neither the frontend code, nor its tests are directly available to the test runner.

To ensure that the tests are run in the same environment and conditions as the application, federated tests are prepared and served by the backend server, to the frontend test runner. This approach does make unit testing a bit unconventional, but it ensures that the environment the tests run in is the same as the one the application runs in.

vite-plugin-federation creates a static build of federated components, which are then exposed over the network, and then the federation's internal mechanism uses import() to import those components. As Node.js does not support importing files over http, testing federation properly means we have to use a browser-like environment to run the tests.

vitest, the test runner that we're using for unit tests, supports browser mode, which allows us to run tests inside an actual browser, through Playwright. This abstraction, while flagged as experimental at the time of writing, works with minimal changes to the test configuration, and with no changes to the actual tests themselves.

Testing

The plugins' JSX lifecycle looks like this:

  • The plugins are loaded by the backend server
  • The plugins' JSX is collected by the backend server for federation build
    • Optionally, test JSX files are collected if the testing flag is enabled
  • The plugins' JSX is built by the backend server
  • The plugins' JSX is exposed by the backend server
  • Frontend loads the federation configuration from backend
    • Optionally, test configuration is loaded from backend if the testing flag is enabled
  • Frontend loads the federated components from the backend
    • Test files can also be loaded. These do not contain any components, but rather contain test blocks that will be executed by the test runner

In Python code's case, the tests have been isolated into a separate package, so that the final deployment does not include the tests. While we could technically do the same for JSX code, we chose not to, as it would provide for a subpar development experience for a few reasons:

  • As the JSX files are collected and built in an environment that is created specifically for the build, if tests were located outside the location where other JSX lives, import statements would not reflect the actual location of the files, looking like "invalid" imports
  • With the "invalid" import statements, IDE support would be limited to nonexistent, as the IDE would not be able to resolve the imports
  • The JSX testing framework would become much more complex - with Python code, we simply install a package in a native way, while with JSX, we would need to do some guess work to figure out where the tests are located, and where the files they test are located, which could question the reliability of the test suite in a more complex plugins & scenarios

Vitest discovery explained

Vitest performs test discovery during its initial module graph scan, so tests must be statically defined at the top level of the module, so they’re picked up as the file is parsed.

Let's say we're importing some module containing tests by using import('testfile') (federation works slightly different from this, this is just for illustration purposes). In the following example:

// When you import a file, tests "defined" merely by importing their file will
// be collected and included in the test suite
const mod = await import('testfile');

// However, any test produced "dynamically", e.g. by using a function, will not
// be collected:
mod.default();  // too late, already missed the collection phase

In the federation case, the test files are not imported directly, but rather through the federation's mechanism, defined in federationUnitTestRunner.js, which, with the top-level awaits, imports all the test files so that they can be collected in time. Any federated module containing .test. in its name will be imported at that time, and any top-level describe/it calls will be collected and included in the test suite.

Running the test suite

By default, the test files (files ending with .test.jsx) are not included in the build, as they are not needed for the application to run. However, when running the test suite, we need to include them in the build, so that they can be loaded by the frontend test runner. This is done by setting the JSX_INCLUDE_TESTS environment variable when running the backend, which will ensure that test files are collected as well.

To run the test suite, you first need to run the backend server with the testing flag enabled (make target sets the environment variable):

docker exec -it backend-dev make run-federation-test

Then, in a separate shell, run:

docker exec -it frontend-dev make federation_test

This will run the test suite in the headless browser, using Playwright. In case of any failures, you might notice that the test runner does not provide the standard stacktrace you would otherwise expect. This is because of how vite-plugin-federation handles the module loading. Additionally, for the time being, debugging is not possible, as the federation-provided are dynamically built, and the IDE cannot resolve the paths to the files.

Migration from vite-plugin-federation to module-federation is planned, which might remedy some of these problems. For more info, take a look at GitLab issue #249.