gren-unit-node

A test framework for Gren Node applications. Write your tests in Gren, orgranizing them into test suites. Run them from the command line and get a pass/fail report with per-suite timing, or per-test timing with -v.

Example output: run all tests and see a per-suite summary

% node app

Import Statements                                    ok    8/8    (70 ms)
Expressions                                          ok    54/54  (471 ms)
Declarations                                         ok    5/5    (23 ms)
Comments                                             ok    39/39  (260 ms)
Kitchen sink                                         ok    11/11  (649 ms)


Ran 117 tests in 1473 ms

OK — 117 passed

Example output: run one suite and see per-test details

$ node app -v 'Declarations.*'

Declarations.type aliases                            ok    (15 ms)
Declarations.union types                             ok    (6 ms)
Declarations.type signatures                         ok    (13 ms)
Declarations.ports                                   ok    (4 ms)
Declarations.effect module where { command = ... } renders each binding as one field ok    (7 ms)


Ran 5 tests in 45 ms

OK — 5 passed

Setup

Create a tests/ application directory alongside your package or application. Add gilramir/gren-unit-node as a dependency in tests/gren.json. For example:

{
    "type": "application",
    "platform": "node",
    "source-directories": [ "src" ],
    "gren-version": "0.6.5",
    "dependencies": {
        "direct": {
            "gilramir/gren-unit-node": "1.0.0 <= v < 2.0.0",
            "gren-lang/core": "7.4.2",
            "gren-lang/node": "6.1.0",
            "gren-lang/test": "5.0.0"
        },
        "indirect": {
            "gren-lang/url": "6.0.0"
        }
    }
}

Your first test

Create tests/src/Main.gren. The only required pieces are a U.run call in main and at least one suite containing the tests:

module Main exposing (main)

import Expect
import Node
import Task
import Test.Runner.UnitNode as U


main : Node.SimpleProgram a
main =
    U.run
        { name = "my-tests"
        , version = "1.0.0"
        , suites = [ mathSuite ]
        }


mathSuite : U.Suite
mathSuite =
    U.suite
        { name = "Math"
        , setUpSuite = U.noSuiteFixture
        , tearDownSuite = U.noTearDown
        , setUp = U.noFixture
        , tearDown = U.noTearDown
        , tests =
            [ U.test "addition" <| \_ ->
                Task.succeed (Expect.equal 4 (2 + 2))
            , U.test "subtraction" <| \_ ->
                Task.succeed (Expect.equal 1 (3 - 2))
            ]
        }

Each test body must return Task String Expectation. Expect.equal 4 (2 + 2) is a pure expression — no effects — so Task.succeed fits it into the Task type the framework expects. Tests that do file I/O or spawn processes already return a Task, so they use Task.map to produce the Expectation at the end instead.

For example, a test that reads a file uses Task.map to turn the bytes into an Expectation:

U.test "config file has expected content" <| \perms ->
    FileSystem.readFile perms.fs (Path.fromPosixString "config.txt")
        |> Task.mapError Debug.toString
        |> Task.map (\bytes ->
                Expect.equal "enabled=true\n" (Bytes.toString bytes |> Maybe.withDefault ""))

Task.mapError converts any filesystem error into the String the framework uses as a failure message. perms is a permissions record provided by U.runWith — see "Tests that need resources" below.

Use the standard Expect.* functions from gren-lang/test for assertions.

U.run requires no permissions. For tests that read files, spawn processes, or touch other external resources, use U.runWith — see below.

Build and run

To run the tests, you can compile your test application and run it.

gren make Main --output=app
node app

or just have gren run it:

gren run Main

The output shows the test suites that were run, the result, the number of tests passed out of the total number of tests, and the execution duration.

Math                                     ok    2/2    (12 ms)


Ran 2 tests in 12 ms

OK — 2 passed

Add -v for a line per test. Here, "gren run" cannot be used as it cannot handle the additional options to the node application.

node app -v

Here you see the status and execution duration of each individual test.

Math.addition                            ok    (6 ms)
Math.subtraction                         ok    (6 ms)


Ran 2 tests in 12 ms

OK — 2 passed

Selecting tests

If you want to run one or more tests, but not all the tests, pass one or more glob patterns on the command-line. Test names are fully-qualified as Suite.test. Most likely you will want to esacpe the glob patterns with single-quotes to avoid your shell from expanding them, looking for files.

node app 'Math.*'        # every test in the Math suite
node app '*addition*'    # any test whose name contains "addition"
node app 'A.*' 'B.*'     # multiple patterns are a union
node app --list          # print all qualified names, don't run

Tests that need resources

When tests need files, processes, or other external state, use U.runWith to acquire permissions, then use setUp and tearDown to manage per-test resources. tearDown is guaranteed to run even if the test fails, so resources are always cleaned up.

Acquiring permissions

U.runWith takes an init field that follows Gren's Init.await continuation style: call done with whatever permissions your tests need. The most common case is filesystem access:

import FileSystem
import FileSystem.Path as Path exposing (Path)
import Init
import Node
import Task
import Test.Runner.UnitNode as U


type alias Perms =
    { fs : FileSystem.Permission }


main : Node.SimpleProgram a
main =
    U.runWith
        { name = "my-tests"
        , version = "1.0.0"
        , init =
            \done ->
                Init.await FileSystem.initialize <| \fs ->
                    done { fs = fs }
        , globalSetUp = U.noFixture
        , globalTearDown = U.noTearDown
        , suites = \perms -> [ tempDirSuite perms ]
        }

Your suites function receives whatever record you passed to done. Use U.noFixture and U.noTearDown as no-ops when you don't need a global lifecycle.

Per-test setup and teardown

Here each test gets a fresh temporary directory, created in setUp and removed in tearDown. The framework runs setUp before each test and passes whatever its Task resolves to as the argument to that test's body — so \dir -> receives the Path that setUp produced.

tempDirSuite : Perms -> U.Suite
tempDirSuite perms =
    U.suite
        { name = "TempDir"
        , setUpSuite = U.noSuiteFixture
        , tearDownSuite = U.noTearDown
        , setUp =
            \_ ->
                FileSystem.makeTempDirectory perms.fs "my-tests"
                    |> Task.mapError (\e -> "setUp: " ++ Debug.toString e)
        , tearDown =
            \dir ->
                FileSystem.remove perms.fs { recursive = True } dir
                    |> Task.map (\_ -> {})
                    |> Task.mapError (\e -> "tearDown: " ++ Debug.toString e)
        , tests =
            [ U.test "creates a file" <| \dir ->
                let
                    file =
                        Path.append (Path.fromPosixString "hello.txt") dir
                in
                FileSystem.writeFile perms.fs (Bytes.fromString "hello") file
                    |> Task.map (\_ -> Expect.pass)
                    |> Task.mapError (\e -> "write failed: " ++ Debug.toString e)
            ]
        }

Suite-level setup

setUpSuite and tearDownSuite run once for the whole suite — useful for expensive one-time work like locating a binary. The value setUpSuite produces is passed to every setUp call as its first argument:

U.suite
    { name = "CLI"
    , setUpSuite =
        FileSystem.realPath perms.fs (Path.fromPosixString "../app")
            |> Task.map Path.toPosixString
            |> Task.mapError (\e -> "could not find app: " ++ Debug.toString e)
    , tearDownSuite = U.noTearDown
    , setUp = \appPath -> Task.succeed appPath
    , tearDown = U.noTearDown
    , tests = [ ... ]
    }

Global setup and teardown

globalSetUp and globalTearDown run once around the entire test run — before any suite starts and after all suites finish. Both receive the same perms record as suites. Use them for expensive one-time work that spans all suites, such as starting a server or seeding a database:

main : Node.SimpleProgram a
main =
    U.runWith
        { name = "my-tests"
        , version = "1.0.0"
        , init =
            \done ->
                Init.await FileSystem.initialize <| \fs ->
                Init.await ChildProcess.initialize <| \cp ->
                    done { fs = fs, childProcess = cp }
        , globalSetUp = \perms -> startTestServer perms.childProcess
        , globalTearDown = \perms -> stopTestServer perms.childProcess
        , suites = \perms -> [ apiSuite perms ]
        }

If globalSetUp fails, no suites run and globalTearDown is skipped. If globalTearDown fails, the program exits with an error after the report has been printed.

Multiple permissions

Chain Init.await calls to acquire more than one permission:

init =
    \done ->
        Init.await FileSystem.initialize <| \fs ->
        Init.await ChildProcess.initialize <| \cp ->
            done { fs = fs, childProcess = cp }

Error channel

Every lifecycle function and every test body works in Task String _. When a task fails, put a human-readable message in the error channel; the framework prints it verbatim in the failure report.

Note: U.runWith always acquires its own FileSystem.Permission internally for --junit-xml support. If your tests also need filesystem access, acquire it separately in your init.

Comparing output against a file

A common pattern is to keep expected output in a file in a directory (in this example, testfiles/) and compare it against what your code produces. FileSystem.readFile returns a Task, so the comparison lives inside Task.map:

import Bytes
import FileSystem
import FileSystem.Path as Path
import Task
import Expect
import Test.Runner.UnitNode as U


goldenSuite : Perms -> U.Suite
goldenSuite perms =
    U.suite
        { name = "Golden"
        , setUpSuite = U.noSuiteFixture
        , tearDownSuite = U.noTearDown
        , setUp = U.noFixture
        , tearDown = U.noTearDown
        , tests =
            [ U.test "render matches expected output" <| \_ ->
                let
                    expectedFile =
                        Path.fromPosixString "testfiles/expected-output.txt"
                in
                FileSystem.readFile perms.fs expectedFile
                    |> Task.mapError (\e -> "could not read expected file: " ++ Debug.toString e)
                    |> Task.map
                        (\bytes ->
                            let
                                expected =
                                    Bytes.toString bytes |> Maybe.withDefault ""

                                actual =
                                    myRenderFunction someInput
                            in
                            Expect.equal expected actual
                        )
            ]
        }

Bytes.toString returns a Maybe String because not all byte sequences are valid UTF-8; Maybe.withDefault "" is fine for text fixture files. myRenderFunction is whatever pure function you are testing — no effects needed on that side, so it just goes in the let.

What happens when things go wrong

Situation Result
globalSetUp fails No suites run; program exits with error; globalTearDown does not run
setUpSuite fails Every test in the suite is marked Errored; tearDownSuite does not run
setUp fails That test is marked Errored; its tearDown does not run
Test body fails an assertion Test is marked Failed
Test body task errors Test is marked Errored
tearDown fails on a passing test Test becomes Errored
tearDown fails on an already-failed test Original failure is kept
tearDownSuite fails Recorded as a suite-level error alongside the test results
globalTearDown fails Program exits with error after the report is printed

All tests in a suite always run — there is no bail-out on first failure.

Other CLI flags

node app --junit-xml results.xml   # also write JUnit XML (useful in CI)

Design notes

Why another Gren test runner?

This gren-unit-node package exists because I wanted to include timing information and setup and teardown functions. So we can't run all the test tasks first and the evaulate the resulting Expectation values together. In the gren-unit-node model, the ordering and timing of individual steps matters:

  • Per-test setUp and tearDown. A tearDown that must release the resource its test acquired and do so before the next setUp runs.

  • Setup failures as first-class outcomes. When setUp fails, the right behavior is to mark that test as Errored, skip its body entirely, and still run tearDown for any fixtures that did get created.

  • Per-test timing. Knowing how long each individual test took requires observing when each one starts and finishes, which is difficult to recover after a batch run.

  • Machine-readable output. JUnit XML and similar formats need per-test outcomes with lifecycle data attached — the same information that per-test timing requires.

gren-unit-node addresses these by owning a sequential loop over Task. Each test runs completely — setUp → body → tearDown — as a single composed Task before the next test begins. A failing setUp becomes a Task error the runner catches and records as an Errored outcome; tearDown is chained in a way that is structurally guaranteed to run even when the body fails.

The assertion API is the same: gren-unit-node reuses gren-lang/test's Expect.* matchers throughout, pulling structured failure information out of an Expectation via Test.Runner.getFailureReason. You do not need to learn a new assertion library to use it.