In the object-oriented programming circles, people often talk about creating testable architecture that goes by different names:
- functional core, imperative shell
- ports and adapters architecture
- and many others (onion, hexagonal, clean etc.)
You can read lengthy books on how to achieve the holy grail of architecture, and usually, much effort is required.
Functional architecture imposed by Hyperapp makes the holy grail a default. As Mark Seemann noted in his blog "functional architecture tends to fall into a pit of success".
You can visualize your app as the state in the center. Actions and view declarations are sitting around it. And effects/subscriptions/DOM manipulation at the edges.
Your application is as a functional core with pure view functions, pure actions and immutable state.
The framework is an imperative shell sitting at the edges, interpreting effectful actions and handling side-effects.
The functional core makes decisions and defines the shape of your UI, the shell converts the decisions into side effects, gathers the inputs and renders physical DOM nodes.
Your effect interface is a port to the external world. The actual effect definition invoked by the framework is an adapter.
In terms of testability, functional core allows for simple output-based unit testing:
- invoke a function
- assert on the output
- skip the mocks entirely
To make code easier to test separate the app
call from the application building blocks: state, view, actions and subscriptions.
App.js should setup Hyperapp:
import { app } from "hyperapp";
import {init, view, subscriptions} from "./Posts.js";
app({
init,
view,
subscriptions,
node: document.getElementById("app"),
});
Posts.js should export application building blocks for App.js:
export const view = (state) => html`
...
`;
export const subscriptions = (state) => [
state.liveUpdate &&
EventSourceListen({
action: SetPost,
url: "http://hyperapp-api.herokuapp.com/api/event/post"
}),
];
export const init = [state, LoadLatestPosts];
You will use mocha as a test runner and a test reporter.
Why mocha?
- it has native EcmaScript Modules (ESM) support so you don't need a transpiler in testing. Less tooling is always better. We don't want the test framework to run code through babel if it doesn't need to.
- it doesn't try to be a mocking framework encouraging questionable testing practices like overwriting imports for testability. Relying on a test framework to mock imports is a dead-end testing strategy. You're not resolving the tight coupling in your code so your code can't be tested in other test runners. We prefer to rethink code structure to make it testable in every test runner.
- it has fast, clean startup time that allows for subsecond tests without watchers. It's difficult to explain why the subsecond test suite is so important without experiencing it first-hand. We can only encourage you to give it a try. You may never want to go back to a typical slow testing setup. It's something to think about every time you run your tests, and you look at your test frameworks doing its setup instead of running your tests.
- it runs your tests in Node.js and in a browser.
Our main reservation about mocha is that it can't run plain Node.js files as tests, but it's a minor nuisance compared to the benefits it provides.
Make sure you have Node 14 or higher installed as it ships with native ESM support.
Include those changes in package.json:
{
"type": "module",
"scripts": {
"test": "mocha \"src/**/*.test.js\""
/* keep the rest of scripts */
},
"devDependencies": {
"mocha": "8.3.2",
/* keep the rest of dependencies */
}
}
This code:
- tells Node.js to use ESM modules (
"type": "module"
) - runs mocha
- adds mocha as a development dependency
Now install mocha by running:
npm i
Write your first test in src/Posts.test.js:
import assert from "assert";
import { UpdatePostText } from "./Posts.js";
describe("Posts:", () => {
it("post text is updated", () => {
const initState = {
currentPostText: "",
requestStatus: { status: "idle" },
};
const newState = UpdatePostText(initState, "text");
assert.deepStrictEqual(newState, {
currentPostText: "text",
requestStatus: { status: "idle" },
});
});
});
This code uses Node.js built-in assert
module.
The test:
- prepares initial state
- invokes an action with a new post text
- verifies expected state changes
You can think of this test as three blocks separated by empty spaces:
- given
- when
- then
Run the test:
npm test
The test runner should report that UpdatePostText
is not exported.
Add the export
keyword:
export const UpdatePostText = (state, currentPostText) => ({
...state,
currentPostText,
requestStatus: idle,
});
The test should go green.
Note: We encourage you to get into the habit of seeing a failing test first. If you haven't seen the falling test, you can't be sure if the test even run. It may sound silly, but it happens more often than any experienced programmer would want to admit (ourselves included).
One of the tradeoffs of actions unit testing is that you need to expose them in the public API of the tested module.
Write a unit test verifying UpdatePostText
resets error request status to idle.
const initState = {
currentPostText: "",
requestStatus: {
status: "error",
error: "oh nooo"
},
};
Solution
it("post status is reset to idle", () => {
const initState = {
currentPostText: "",
requestStatus: { status: "error", error: "oh nooo" },
};
const newState = UpdatePostText(initState, "text");
assert.deepStrictEqual(newState, {
currentPostText: "text",
requestStatus: { status: "idle" },
});
});
The AddPost
action is difficult to unit test because it relies on Math.random()
for guid generation.
More testable implementation would take id
as an input parameter:
const AddPost = (state, id) => {
// ...
};
Find usage of AddPost
and replace it with:
const addPostButton = ({ status }) => html`
<button onclick=${WithGuid(AddPost)} disabled=${status === "saving"}>
Add Post
</button>
`;
WithGuid
doesn't exist yet. You're only sketching future API in code. It's a similar technique to TDD.
You let the usage of the API to guide its shape.
WithGuid
should tell Hyperapp to generate a new id and pass it to a testable action:
const WithGuid = (action) => (state) => [state, Guid(action)];
WithGuid
is a function creating an effectful action. Guid
itself is an effect. The only difference with the Http
effect is that you don't need any additional data
so you're passing action directly.
const Guid = (action) => [
(dispatch, action) => {
dispatch(action, guid());
},
action,
];
If you have any problems following this code, revisit Chapter 6: Effects as data.
Test AddPost
action.
import { UpdatePostText, AddPost } from "./Posts.js";
it("add post", () => {
const initState = {
currentPostText: "text",
requestStatus: { status: "idle" },
posts: [],
};
const [newState, [savePostEffect, savePostData]] = AddPost(
initState,
"1234"
);
assert.deepStrictEqual(newState, {
currentPostText: "",
requestStatus: { status: "saving" },
posts: [],
});
assert.deepStrictEqual(
savePostData.url,
"https://hyperapp-api.herokuapp.com/api/post"
);
});
Effectful actions return new state and one or many effects. Destructure the effect to assert on its data conveniently.
AddPost
:
- should clear the
currentPostText
- should mark the request as
saving
- should not add any posts to the
posts
list yet
Ignore the savePostEffect
as it's only for the framework.
However, you can verify if the savePostData
effect got correct data. In this case, check the url
.
To emphasize that you don't verify the effect definition, name it with _
or leave a blank in the destructured array.
const [newState, [_, savePostData]] = AddPost(initState, "1234");
const [newState, [, savePostData]] = AddPost(initState, "1234");
Run the test and check if it's green. It should be red. You will need to export the AddPost
action.
Before you start testing effects and subscriptions separate them from the rest of the code. Create src/lib directory and move SSE and guid related code there. Remember to export appropriate functions.
src/lib/EventSource.js
const eventSourceSubscription = (dispatch, data) => {
const es = new window.EventSource(data.url);
const listener = (event) => dispatch(data.action, event);
es.addEventListener("message", listener);
return () => {
es.removeEventListener("message", listener);
es.close();
};
};
export const EventSourceListen = (data) => [eventSourceSubscription, data];
src/lib/Guid.js
const guid = () => {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
const Guid = (action) => [
(dispatch, action) => {
dispatch(action, guid());
},
action,
];
export const WithGuid = (action) => (state) => [state, Guid(action)];
In Posts.js you should have those imports:
import { EventSourceListen } from "./lib/EventSource.js";
import { WithGuid } from "./lib/Guid.js";
Effects and subscriptions live at the edges of the system and interact with browser APIs outside of your control, e.g. DOM API or Fetch API. Therefore, effects and subscriptions are more difficult to unit test. It's usually a job of the effect library author.
Create src/lib/Http.test.js:
import assert from "assert";
import { Http } from "./Http.js";
const runEffect = async ([effect, data]) => {
let invokedWith;
const dispatch = (action, data) => (invokedWith = [action, data]);
await effect(dispatch, data);
return invokedWith;
};
global.window = {};
global.window.fetch = (url) => {
if (url.includes("error")) {
return Promise.resolve({
ok: false,
status: 500,
json() {
return "API error";
},
});
}
return Promise.resolve({
ok: true,
status: 200,
json() {
return "API data";
},
});
};
describe("Http", function () {
it("happy path", async function () {
const http = Http({ url: "some url", action: "some action" });
const dispatched = await runEffect(http);
assert.deepStrictEqual(dispatched, ["some action", "API data"]);
});
});
Take your time to scan the test visually.
runEffect
simulates Hyperapp dispatching effects and invokedWith
records interaction without using a mocking framework.
We verify if correct action got dispatched with an expected body. To simulate API success or failure we can handcraft
sample responses and attach them to window.fetch
.
The code and tests for effects/subscriptions are conceptually more complicated than the rest of the code. It's the inherent complexity of the Web platform. Because this code is not very convenient to work with Hyperapp keeps it at the edges and doesn't allow your application code to get polluted.
All those callback event listeners and eagerly resolving promises (e.g. fetch
) are hidden away from your application logic.
Write a second test that verifies the unhappy path with the error action.
Solution
it("unhappy path", async function () {
const http = Http({
url: "error url",
action: "some action",
error: "error action",
});
const dispatched = await runEffect(http);
assert.deepStrictEqual(dispatched, ["error action", "API error"]);
});
Setting global.window.fetch may affect other tests. Your task will be to extract fetch to a regular variable. Next, expose setFetch function from the Http module to set custom fetch before each test. After each test reset fetch to window.fetch with a new resetFetch function.
import { Http, setFetch, resetFetch } from "./Http.js";
// inside describe
beforeEach(function () {
setFetch(fetch);
});
afterEach(function () {
resetFetch();
});
Solution
lib/Http.js
let fetch = typeof window !== "undefined" && window.fetch;
const fetchEffect = (dispatch, data) => {
return fetch(data.url, data.options)
.then((response) =>
response.ok ? response : Promise.reject(response.json())
)
.then((response) => response.json())
.then((json) => {
return dispatch(data.action, json);
})
.catch((e) => dispatch(data.error, e));
};
export const Http = (data) => [fetchEffect, data];
export const setFetch = (newFetch) => (fetch = newFetch);
export const resetFetch = () => (fetch = window.fetch);