Skip to content

Commit

Permalink
Add ESLint, Vitest, JSDOM+ESM workaround, JS test
Browse files Browse the repository at this point in the history
Includes a workaround for JSDOM's current lack of <script type="module">
support, specifically when loading a HTML file:

- jsdom/jsdom#2475

The importModules() helper method uses dynamic import() to apply
JavaScript modules after JSDOM has parsed, but not executed,
<script type="module"> elements.

The comments for importModules() and loadFromFile(), both currently in
main.test.js, contain further explanation.

(I may eventually extract these into a helper module, post them to the
aforementioned issue, write a blog post about them, etc.)

---

This problem arose because Vite depends on browser support for
JavaScript modules (a.k.a. ES modules):

- https://vitejs.dev/guide/build.html#browser-compatibility
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
- https://nodejs.org/dist/latest-v20.x/docs/api/esm.html

The Vitest "Hello, World!" test currently uses JSDOM to emulate a
browser (I'll add Vitest experimental browser support shortly):

- https://github.com/jsdom/jsdom

However, it took me a few hours to figure out that JSDOM parses, but
doesn't execute, `<script type="module">`, which is essential to how
Vite works:

- https://vitejs.dev/guide/#index-html-and-project-root

My original solution was to also include a `<script nomodule>` element
to load the same file, but that wasn't ideal. It also broke down when I
split `initApp` into init.js to force main.js to declare an `import`
statement.

The importModules() solution is far more robust. Given the constraints
with regard to event listeners mentioned in its function comment, it
provides a seamless solution. As described by the loadFromFile()
comment, when JSDOM supports <script type="module">, removal of
importModules() should prove straightforward.
  • Loading branch information
mbland committed Nov 13, 2023
1 parent 9ee83ae commit c7aa7e5
Show file tree
Hide file tree
Showing 11 changed files with 1,939 additions and 48 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ htmlReport
strcalc/src/main/webapp

node_modules
coverage
57 changes: 48 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ The plan is to develop an exercise comprised of the following steps:
- Developing the String Calculator using TDD and small unit tests.
- Adding a medium integration test to ensure the Servlet passes parameters to
the internal String Calculator logic and passes back the results.
- Adding unit tests for JavaScript components, likely incorporating the [Mocha
testing framework][], [Chai assertion library][], and [Sinon test double framework][].
- Adding tests for frontend JavaScript components.
- Using [test doubles][] in unit tests. This may involve extending the String
Calculator example or adding a completely different one, possibly based on
[Apache Solr][].
Expand Down Expand Up @@ -611,10 +610,42 @@ TODO(mbland): Document how the following are configured:
- [Selenium WebDriver][]
- [TestTomcat](./strcalc/src/test/java/com/mike_bland/training/testing/utils/TestTomcat.java)
(for medium tests)
- [Vite JavaScript development environment][]
- [pnpm Node.js package manager][]
- [node-gradle/gradle-node-plugin][]

## Setup frontend JavaScript environment

[Node.js][] is a JavaScript runtime environment. [pnpm][] is a Node.js package
manager.

- TODO(mbland): Document usage of [nodenv][], [Homebrew][]

[ESLint][] is a tool for formatting and linting JavaScript code.

[Vite][] is a JavaScript development and deployment platform. [Vitest][] is a
JavaScript test framework and runner designed to work well with Vite.

Though I've had a great experience testing with Mocha, Chai, and Sinon in the
past, setting them up involves a bit more work.

- [Mocha test framework][]
- [Chai test assertion library][]
- [Sinon test double framework][]

In contrast, Vitest is largely modeled after the popular Jest framework and is a
breeze to set up, especially for existing Vite projects. Like Jest, it contains
its own assertion library and test double framework. For the purpose of a
teaching example for people who may never have tested JavaScript before, but
aren't using React, Vitest seems much more accessible.

Suffice it to say, ESLint and Vite have IntelliJ IDEA and Visual Studio
Code support:

- ESLint in IntelliJ IDEA: _Settings > Languages & Frameworks >
JavaScript > Code Quality Tools > ESLint_
- [ESLint extension for Visual Studio Code][]
- [Vite IntelliJ plugin][]
- [Vite extension for Visual Studio Code][]

## Implementing core logic using Test Driven Development and unit tests

Coming soon...
Expand All @@ -630,9 +661,6 @@ Coming soon...
[walking skeleton]: https://wiki.c2.com/?WalkingSkeleton
[Selenium WebDriver]: https://www.selenium.dev/documentation/webdriver/
[headless Chrome]: https://developer.chrome.com/blog/headless-chrome/
[Mocha testing framework]: https://mochajs.org/
[Chai assertion library]: https://www.chaijs.com/
[Sinon test double framework]: https://sinonjs.org/
[test doubles]: https://mike-bland.com/2023/09/06/test-doubles.html
[Apache Solr]: https://solr.apache.org/
[HTML &lt;form&gt;]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form
Expand Down Expand Up @@ -688,6 +716,17 @@ Coming soon...
[coverallsapp/github-action GitHub Actions plugin]: https://github.com/coverallsapp/github-action
[GitHub Actions marketplace]: https://github.com/marketplace?type=actions
[JaCoCo related GitHub Actions plugins]: https://github.com/marketplace?category=&type=actions&verification=&query=jacoco
[Vite JavaScript development environment]: https://vitejs.dev/
[pnpm Node.js package manager]: https://pnpm.io/
[node-gradle/gradle-node-plugin]: https://github.com/node-gradle/gradle-node-plugin
[Node.js]: https://nodejs.org/
[pnpm]: https://pnpm.io/
[nodenv]: https://github.com/nodenv/nodenv
[homebrew]: https://brew.sh/
[ESLint]: https://eslint.style/
[Vite]: https://vitejs.dev/
[Vitest]: https://vitest.dev/
[Mocha test framework]: https://mochajs.org/
[Chai test assertion library]: https://www.chaijs.com/
[Sinon test double framework]: https://sinonjs.org/
[ESLint extension for Visual Studio Code]: https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint
[Vite IntelliJ plugin]: https://plugins.jetbrains.com/plugin/20011-vite
[Vite extension for Visual Studio Code]: https://marketplace.visualstudio.com/items?itemName=antfu.vite
4 changes: 4 additions & 0 deletions strcalc/src/main/frontend/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/vendor/*.js
coverage/*
tmp/
node_modules/
45 changes: 45 additions & 0 deletions strcalc/src/main/frontend/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{ "extends": "eslint:recommended",
"env" : {
"browser" : true,
"node": true,
"es2023" : true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [ "@stylistic/js", "vitest" ],
"overrides": [
{
"files": ["**/*.test.js"],
"plugins": ["vitest"],
"extends": ["plugin:vitest/recommended"]
}
],
"rules" : {
"@stylistic/js/comma-dangle": [
"error", "never"
],
"@stylistic/js/indent": [
"error", 2, { "VariableDeclarator": 2 }
],
"@stylistic/js/keyword-spacing": [
"error"
],
"@stylistic/js/max-len": [
"error", 80, 2
],
"@stylistic/js/quotes": [
"error", "single"
],
"@stylistic/js/semi": [
"error", "never"
],
"camelcase": [
"error", { "properties": "always" }
],
"no-console": [
"error", { "allow": [ "warn", "error" ]}
]
}
}
4 changes: 3 additions & 1 deletion strcalc/src/main/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>String Calculator - Mike Bland Training</title>
<link rel="stylesheet" href="./style.css" />
<script type="module" src="./main.js"></script>
</head>
<body>
<div id="app"></div><script type="module" src="/main.js"></script>
<div id="app"></div>
</body>
</html>
7 changes: 7 additions & 0 deletions strcalc/src/main/frontend/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-env browser */

export default function initApp(document) {
document.querySelector('#app').innerHTML = `
<p class="placeholder">Hello, World!</p>
`
}
8 changes: 4 additions & 4 deletions strcalc/src/main/frontend/main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import './style.css'
/* eslint-env browser */

document.querySelector('#app').innerHTML = `
<p class="placeholder">Hello, World!</p>
`
import initApp from './init.js'

(() => initApp(document))()
68 changes: 68 additions & 0 deletions strcalc/src/main/frontend/main.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-env browser, node, jest, vitest */
'use strict'
import { describe, expect, test } from 'vitest'
import { JSDOM } from 'jsdom'

// Returns window and document objects from a JSDOM-parsed HTML file.
//
// It will execute <script type="module"> elements with a `src` attribute,
// but not those with inline code. See the comment for importModules().
//
// Based on hints from:
// - https://oliverjam.es/articles/frontend-testing-node-jsdom
let loadFromFile = async (filePath) => {
let dom = await JSDOM.fromFile(
filePath, { resources: 'usable', runScripts: 'dangerously' }
)

// Once importModules() goes away, wrap the return value in a Promise that
// resolves via dom.window.addEventListener('load', ...).
await importModules(dom)
return { window: dom.window, document: dom.window.document }
}

// Imports <script type="module"> elements parsed, but not executed, by JSDOM.
//
// Only works with scripts with a `src` attribute; it will not execute inline
// code.
//
// Remove this function once "jsdom/jsdom: <script type=module> support #2475"
// has been resolved:
// - https://github.com/jsdom/jsdom/issues/2475
//
// Note on timing of script execution
// ----------------------------------
// By the time the dynamic import() calls registered by importModules() begin
// executing, the window's 'DOMContentLoaded' and 'load' events will have
// already fired. Technically, the imported modules should execute similarly
// to <script defer> and execute before 'DOMContentLoaded'. As a result, we
// can't register handlers for these events in our module code. We can add these
// handlers in inline <script>s, but those can't reference module code and
// expect JSDOM tests to work at the moment.
//
// All that said, these should prove to be corner cases easily avoided by sound,
// modular app architecture.
let importModules = async (dom) => {
let modules = Array.from(
dom.window.document.querySelectorAll('script[type="module"]')
)

// The JSDOM docs advise against setting global properties, but we don't
// have another option given the module may access window and/or document.
global.window = dom.window
global.document = dom.window.document
await Promise.all(modules.map(s => import(s.src)))
global.window = global.document = undefined
}

describe('String Calculator UI', () => {
describe('initial state', () => {
test('contains the "Hello, World!" placeholder', async () => {
let { document } = await loadFromFile('./index.html')

let e = document.querySelector('#app .placeholder')

expect(e.textContent).toContain('Hello, World!')
})
})
})
20 changes: 16 additions & 4 deletions strcalc/src/main/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
{
"name": "frontend",
"name": "strcalc-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"lint": "eslint --color --max-warnings 0 .",
"test": "vitest",
"test:run": "vitest --run",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
},
"devDependencies": {
"vite": "^4.4.5"
"@stylistic/eslint-plugin-js": "^1.0.1",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "^0.34.6",
"eslint": "^8.53.0",
"eslint-plugin-vitest": "^0.3.9",
"jsdom": "^22.1.0",
"vite": "^4.4.5",
"vitest": "^0.34.6"
}
}
Loading

0 comments on commit c7aa7e5

Please sign in to comment.