Sandwich Tests
One day over drinks at the local bar, Madame Jellobutter tearfully recounts to you how her previous venture, Unicorn BBQ, failed in part because of a terribly buggy and crashy website. It was so traumatizing that she still has nightmares about that website to this day! After the conversation, you decide to write some unit tests.
Install melange-fest
via opam
After asking around in the #melange
channel of the Reason Discord chatroom, you get a recommendation from user MonadicFanatic1984 to try out melange-fest, a library that allows you to write tests in OCaml and run them in Node test runner. You decide to give it a shot!
Run this from your project’s root directory:
opam install melange-fest
opam install melange-fest
Once that finishes, run
opam list
opam list
to see all the packages you’ve installed in your opam switch (more on that later). Scroll down to the entry for melange-fest
and you should see something like this:
melange-fest 0.1.0 A minimal test framework for Melange
melange-fest 0.1.0 A minimal test framework for Melange
Open melange-for-react-devs.opam
and add a corresponding entry in the depends
section for melange-fest
:
depends: [
"ocaml" {>= "5.1.1"}
"reason" {>= "3.10.0"}
"dune" {>= "3.8"}
"melange" {>= "4.0.0-51"}
"reason-react" {>= "0.14.0"}
"reason-react-ppx" {>= "0.14.0"}
"melange-fest" {>= "0.1.0"}
"opam-check-npm-deps" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"ocaml-lsp-server" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"dot-merlin-reader" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"odoc" {with-doc}
]
depends: [
"ocaml" {>= "5.1.1"}
"reason" {>= "3.10.0"}
"dune" {>= "3.8"}
"melange" {>= "4.0.0-51"}
"reason-react" {>= "0.14.0"}
"reason-react-ppx" {>= "0.14.0"}
"melange-fest" {>= "0.1.0"}
"opam-check-npm-deps" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"ocaml-lsp-server" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"dot-merlin-reader" {with-test} # todo: use with-dev-setup once opam 2.2 is out
"odoc" {with-doc}
]
Note that the version number might not be 0.1.0
when you run opam list
. If it’s different, just use that version number instead.
Now if we want to install this project on another computer, we don’t need to manually install melange-fest
; it will be installed along with all the other dependencies when we run opam install . --deps-only
[1] (this is already done for you when you run npm run init
).
Opam switch
An opam switch is an isolated OCaml environment. In this book, we only use local switches, which are similar to Node project directories[2]. You can list all the opam switches on your computer by running
opam switch
opam switch
The output will look something like this:
# switch compiler description
→ ~/melange-for-react-devs ocaml-base-compiler.5.1.1 ~/melange-for-react-devs
default ocaml.5.1.0 default
[NOTE] Current switch has been selected based on the current directory.
The current global system switch is default.
# switch compiler description
→ ~/melange-for-react-devs ocaml-base-compiler.5.1.1 ~/melange-for-react-devs
default ocaml.5.1.0 default
[NOTE] Current switch has been selected based on the current directory.
The current global system switch is default.
As implied by [NOTE]
, you don’t need to manually set the opam switch for your project, the switch is set based on your current working directory[3].
First test
Add a new file src/order-confirmation/SandwichTests.re
and add a simple test to it:
Fest.test("Item.Sandwich.toEmoji", () =>
Fest.expect
|> Fest.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
Fest.test("Item.Sandwich.toEmoji", () =>
Fest.expect
|> Fest.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
You should get this error:
File "docs/order-confirmation/SandwichTests.re", line 2, characters 0-9:
2 | Fest.test("Item.Sandwich.toEmoji", () =>
^^^^^^^^^
Error: Unbound module Fest
File "docs/order-confirmation/SandwichTests.re", line 2, characters 0-9:
2 | Fest.test("Item.Sandwich.toEmoji", () =>
^^^^^^^^^
Error: Unbound module Fest
In order to use a library, you must add it to the libraries
field of the melange.emit
stanza in your dune
file:
(melange.emit
(target output)
(libraries reason-react)
(libraries reason-react melange-fest)
(preprocess
(pps melange.ppx reason-react-ppx))
(module_systems es6)
(runtime_deps
(glob_files *.css)))
(melange.emit
(target output)
(libraries reason-react)
(libraries reason-react melange-fest)
(preprocess
(pps melange.ppx reason-react-ppx))
(module_systems es6)
(runtime_deps
(glob_files *.css)))
Opening a module
We’ll be adding several more tests to this file, and it’ll quickly become tiresome to have to write Fest.test
, Fest.expect
, and Fest.equal
all the time.
The easiest way to save typing is by using a module alias:
module F = Fest;
F.test("Item.Sandwich.toEmoji", () =>
F.expect |> F.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
module F = Fest;
F.test("Item.Sandwich.toEmoji", () =>
F.expect |> F.equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
However, because SandwichTests
is a module for tests and Fest
only contains testing-related functions, it’s reasonable to open the Fest
module and make all its functions available inside the scope of the SandwichTests
module:
open Fest;
test("Item.Sandwich.toEmoji", () =>
expect |> equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
open Fest;
test("Item.Sandwich.toEmoji", () =>
expect |> equal(Item.Sandwich.toEmoji(Portabello), {js|🥪(🍄)|js})
);
WARNING
In most cases, open
should not be used at the toplevel of a module. It usually makes more sense to use a local open which makes all the functions of the opened module available inside the scope of a function or submodule.
Compile with .mjs
extension
Now that your SandwichTests.re
compiles, try running the outputted .js
file in Node:
node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.js
node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.js
You’ll probably see this error:
(node:68498) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/home/fhsu/work/melange-for-react-devs/_build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.js:3
import * as Assert from "assert";
^^^^^^
SyntaxError: Cannot use import statement outside a module
(node:68498) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/home/fhsu/work/melange-for-react-devs/_build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.js:3
import * as Assert from "assert";
^^^^^^
SyntaxError: Cannot use import statement outside a module
Recent versions of Node support ECMAScript modules, but Node uses CommonJS modules by default. Node automatically treats .mjs
files as ECMAScript modules, so you can change the module_systems
field of your melange.emit
stanza to use the .mjs
extension:
(melange.emit
(target output)
(libraries reason-react melange-fest)
(preprocess
(pps melange.ppx reason-react-ppx))
(module_systems es6)
(module_systems (es6 mjs))
(runtime_deps
(glob_files *.css)))
(melange.emit
(target output)
(libraries reason-react melange-fest)
(preprocess
(pps melange.ppx reason-react-ppx))
(module_systems es6)
(module_systems (es6 mjs))
(runtime_deps
(glob_files *.css)))
Now rebuild and run the test on the newly-generated SandwichTests.mjs
file:
npm run build
node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.mjs
npm run build
node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.mjs
The tests should run successfully this time!
Since you’ve changed the extension of your generated JavaScript files to .mjs
, you must also change the reference in src/order-confirmation/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Melange for React Devs</title>
<script type="module" src="../../_build/default/src/order-confirmation/output/src/order-confirmation/Index.js"></script>
<script type="module" src="../../_build/default/src/order-confirmation/output/src/order-confirmation/Index.mjs"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Melange for React Devs</title>
<script type="module" src="../../_build/default/src/order-confirmation/output/src/order-confirmation/Index.js"></script>
<script type="module" src="../../_build/default/src/order-confirmation/output/src/order-confirmation/Index.mjs"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Add test
npm script
To save ourselves from having to repeatedly type the commands to rebuild the project and run the test, add a new npm script to package.json
:
"test": "npm run build && node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.mjs"
"test": "npm run build && node _build/default/src/order-confirmation/output/src/order-confirmation/SandwichTests.mjs"
In the next chapter, we’ll see a way to shorten this script.
Test Item.Sandwich.toPrice
Let’s add a test for Item.Sandwich.toPrice
. However, in its current form, it’s not testable since it’s a nondeterministic function which can return different values depending on what the date is. So first we must refactor it into a pure function, i.e. make it free from side effects. The easiest way to do so is by adding a date
argument:
let toPrice = (~date: Js.Date.t, t) => {
let day = date |> Js.Date.getDay |> int_of_float;
switch (t) {
| Portabello
| Ham => 10.
| Unicorn => 80.
| Turducken when day == 2 => 10.
| Turducken => 20.
};
};
let toPrice = (~date: Js.Date.t, t) => {
let day = date |> Js.Date.getDay |> int_of_float;
switch (t) {
| Portabello
| Ham => 10.
| Unicorn => 80.
| Turducken when day == 2 => 10.
| Turducken => 20.
};
};
To quiet the compiler, you must also update Item.toPrice
accordingly:
let toPrice = t => {
switch (t) {
| Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make())
| Burger(burger) => Burger.toPrice(burger)
| Hotdog => 5.
};
};
let toPrice = t => {
switch (t) {
| Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make())
| Burger(burger) => Burger.toPrice(burger)
| Hotdog => 5.
};
};
Now you can use the Fest.deepEqual function to write the test for Item.Sandwich.toPrice
:
test("Item.Sandwich.toPrice", () => {
let sandwiches: array(Item.Sandwich.t) = [|
Portabello,
Ham,
Unicorn,
Turducken,
|];
// 14 Feb 2024 is a Wednesday
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
expect
|> deepEqual(
sandwiches
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)),
[|10., 10., 80., 20.|] /* expected prices */
);
});
test("Item.Sandwich.toPrice", () => {
let sandwiches: array(Item.Sandwich.t) = [|
Portabello,
Ham,
Unicorn,
Turducken,
|];
// 14 Feb 2024 is a Wednesday
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
expect
|> deepEqual(
sandwiches
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)),
[|10., 10., 80., 20.|] /* expected prices */
);
});
Here we create an array of Item.Sandwich.t
, tranform it to an array of prices, then compare that array with an array of expected prices.
Punning for function arguments
Note that the following chunk of code uses punning:
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
sandwiches
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item))
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
sandwiches
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item))
Punning means that ~date=date
gets shortened to just ~date
.
Type inference
It’s actually not necessary to create a sandwiches
variable, we can feed the array directly to Js.Array.map
:
test("Item.Sandwich.toPrice", () => {
// 14 Feb 2024 is a Wednesday
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|]
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)),
[|10., 10., 80., 20.|],
);
});
test("Item.Sandwich.toPrice", () => {
// 14 Feb 2024 is a Wednesday
let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.);
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|]
|> Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item)),
[|10., 10., 80., 20.|],
);
});
The OCaml compiler can infer that [|Portabello, Ham, Unicorn, Turducken|]
is of type array(Item.Sandwich.t)
because
Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item))
Js.Array.map(~f=item => Item.Sandwich.toPrice(~date, item))
is a function that only accepts an argument of the type array(Item.Sandwich.t)
.
Sugoi! You now have a module for testing sandwich-related logic. In the next chapter, we’ll see how to integrate your tests with the Dune build system.
Overview
- An opam switch is an isolated OCaml environment
- Use
opam list
to see all the packages installed in your current opam switch - Use
opam install
to install third-party packages into the current opam switch - After you install a package, you should:
- Add it to your
.opam
file so that it will be installed with all your other dependencies the next time you runopam install . --deps-only
- Add the corresponding library to the
libraries
field of yourmelange.emit
stanza so your code can use it
- Add it to your
- melange-fest is a testing library that allows you write tests in OCaml and run them in Node test runner
- You can
open
a module to make all its functions available in the current scope, but you should do this sparingly - To generate
.mjs
files that are treated by Node as ECMASCript modules, set themelange.emit
stanza’smodule_systems
field to(es6 mjs)
- Punning shortens function invocations by transforming
~foo=foo
into justfoo
. - Sometimes you can use a value without any type annotation because the compiler can infer the type based on the type signature of the function it’s fed into
Exercises
1. Use Fest.deepEqual to improve the existing test for Item.Sandwich.toEmoji
by testing for all possible outputs.
Solution
test("Item.Sandwich.toEmoji", () => {
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|]
|> Js.Array.map(~f=Item.Sandwich.toEmoji),
[|
{js|🥪(🍄)|js},
{js|🥪(🐷)|js},
{js|🥪(🦄)|js},
{js|🥪(🦃🦆🐓)|js},
|],
)
});
test("Item.Sandwich.toEmoji", () => {
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|]
|> Js.Array.map(~f=Item.Sandwich.toEmoji),
[|
{js|🥪(🍄)|js},
{js|🥪(🐷)|js},
{js|🥪(🦄)|js},
{js|🥪(🦃🦆🐓)|js},
|],
)
});
Note the use of partial application in the callback to Js.Array.map
.
2. Write a new unit test for Item.Sandwich.toPrice
that checks the date-dependent logic for Turducken sandwiches.
Hint
Use Js.Date.makeWithYMD and Js.Array.map
to generate a whole week’s worth of dates. Here’s a relevant playground example.
Solution
test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => {
// Make an array of all dates in a single week; 1 Jan 2024 is a Monday
let dates =
[|1., 2., 3., 4., 5., 6., 7.|]
|> Js.Array.map(~f=date =>
Js.Date.makeWithYMD(~year=2024., ~month=0., ~date)
);
expect
|> deepEqual(
dates
|> Js.Array.map(~f=date => Item.Sandwich.toPrice(~date, Turducken)),
[|20., 10., 20., 20., 20., 20., 20.|],
);
});
test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => {
// Make an array of all dates in a single week; 1 Jan 2024 is a Monday
let dates =
[|1., 2., 3., 4., 5., 6., 7.|]
|> Js.Array.map(~f=date =>
Js.Date.makeWithYMD(~year=2024., ~month=0., ~date)
);
expect
|> deepEqual(
dates
|> Js.Array.map(~f=date => Item.Sandwich.toPrice(~date, Turducken)),
[|20., 10., 20., 20., 20., 20., 20.|],
);
});
3. Refactor the Item.Sandwich.toPrice
test using punning and partial application:
test("Item.Sandwich.toPrice", () => {
/* Write your code here; don't change the rest of the function */
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|] |> Js.Array.map(~f),
[|10., 10., 80., 20.|],
);
});
test("Item.Sandwich.toPrice", () => {
/* Write your code here; don't change the rest of the function */
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|] |> Js.Array.map(~f),
[|10., 10., 80., 20.|],
);
});
Hint
Add a new function f
with the type signature Item.Sandwich.t => float
.
Solution
test("Item.Sandwich.toPrice", () => {
let f =
Item.Sandwich.toPrice(
// 14 Feb 2024 is a Wednesday
~date=Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.),
);
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|] |> Js.Array.map(~f),
[|10., 10., 80., 20.|],
);
});
test("Item.Sandwich.toPrice", () => {
let f =
Item.Sandwich.toPrice(
// 14 Feb 2024 is a Wednesday
~date=Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.),
);
expect
|> deepEqual(
[|Portabello, Ham, Unicorn, Turducken|] |> Js.Array.map(~f),
[|10., 10., 80., 20.|],
);
});
View source code and demo for this chapter.
The
--deps-only
option tellsopam install
to only install the dependencies and not the package. When you’re developing an application and not a library, there is no package associated with your project. ↩︎This is how Node project directories correspond to opam local switches:
packages.json
->.opam
filenode_modules
directory ->_opam
directory
A shell hook is responsible for setting the current opam switch based on the directory you are
cd
-ing into. The shell hook is typically installed when you runopam init
. Just respond withy
when it asksDo you want opam to modify ~/.profile?
Do you want opam to modify ~/.profile?
Note that it may ask to modify
~/.zshrc
or some other file; the name of the file is system-dependent. ↩︎