Symfony Asset Mapper: How to Finally Test JavaScript Properly Without the Pain
You know the drill. Symfony Asset Mapper is a great tool. You've gotten rid of Webpack, npm install, and complex build processes. Everything is fast, clean, and modern. And then comes the fateful question: "And how do we actually test this?" Then comes the reality check. Asset Mapper works on the principle of importmap.php, which your Node.js (and thus most test runners) has no clue about. You try to run a test and you get: ERR_MODULE_NOT_FOUND. Many people just wave it off and say that testing Asset Mapper is simply impossible, or you have to switch back to a complex frontend stack. But what if I told you there's an elegant solution that bridges both worlds? My idea was simple: Node.js expects libraries in the node_modules folder. Symfony has them in assets/vendor/ or in vendor/ (for Stimulus bundles). So why not force Node.js to see what Symfony sees, without having to duplicate anything or "hack" the imports? The solution is a small PHP script that reads your import map and creates a symlink structure in node_modules. Node.js will think everything is installed, while in reality, it will be reading the same files your browser uses. Here is a simplified version of our "linker" script. It uses the Symfony Filesystem component for safe file manipulation. // bin/setup-js-tests.php require_once __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Filesystem\Filesystem; $projectRoot = dirname(__DIR__); $importmap = require $projectRoot . '/importmap.php'; $fs = new Filesystem(); $nodeModules = $projectRoot . '/node_modules'; if (!$fs->exists($nodeModules)) $fs->mkdir($nodeModules); foreach ($importmap as $name => $config) { $targetDir = $nodeModules . '/' . $name; $sourcePath = isset($config['path']) ? $projectRoot . '/' . ltrim($config['path'], './') : $projectRoot . '/assets/vendor/' . $name; if (!$fs->exists($sourcePath)) continue; if (!$fs->exists(dirname($targetDir))) $fs->mkdir(dirname($targetDir)); if (is_dir($sourcePath)) { $fs->symlink($sourcePath, $targetDir); } else { // If it's a single file, we turn it into a package with index.js if (!$fs->exists($targetDir)) $fs->mkdir($targetDir); $fs->symlink($sourcePath, $targetDir . '/index.js'); $fs->dumpFile($targetDir . '/package.json', json_encode([ 'name' => $name, 'type' => 'module', 'main' => 'index.js' ])); } } echo "Imports for JS tests have been successfully linked!\n"; Now we need to tell Node.js how to run the tests. We'll use a standard package.json, but with a small improvement: we'll use the pretest hook, which automatically runs our PHP script before every test. { "name": "my-project", "private": true, "type": "module", "scripts": { "test": "node --test tests/js/*.test.mjs", "test:watch": "node --watch --test tests/js/*.test.mjs", "pretest": "php bin/setup-js-tests.php" } } For testing, we chose the native Node.js test runner (available since version 20). Why? Zero configuration: No need to install Jest, Vitest, or anything similar. Speed: It starts instantly. Reality-based: No complex import mocking. You are testing the exact same files that run in production. Thanks to symlinks, we can write clean imports in our tests: import assert from 'node:assert/strict'; import test from 'node:test'; import { myFunction } from '../assets/js/my-function.js'; import { Midi } from '@tonejs/midi'; // This now works! This approach allows us to follow TDD (Test Driven Development) on the frontend while maintaining the simplicity of Asset Mapper. If you add a new library via importmap:require, just run npm test and everything will be automatically re-linked. Testing JavaScript in Symfony Asset Mapper is not impossible. All it takes is a small bridge in the form of a PHP script, and you can leverage the power of a modern Node.js environment without having to leave the comfort zone of your PHP framework. Give it a try in your project and say goodbye to "missing module" errors. Your code (and your mental health) will thank you. ?
