Gleam Language Testing with Gleeunit: A Practical Guide
Gleam is a statically typed functional language that compiles to Erlang and JavaScript. It brings the reliability of the Erlang VM to a modern, approachable syntax — and its testing story is just as well-considered. If you are building with Gleam, gleeunit is your primary testing tool. This guide covers everything from your first test to CI pipelines.
What Is Gleeunit?
Gleeunit is the standard testing library for Gleam. It wraps Erlang's eunit framework and exposes a clean API that fits naturally into Gleam's functional style. You get:
- A
shouldassertion module with readable failure messages - Test discovery by convention (any public function ending in
_testis a test) - Integration with
gleam test, the built-in test runner command
Gleeunit ships with every new Gleam project by default. If you are adding it to an existing project, add the dependency to gleam.toml:
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"Writing Your First Test
Gleam tests live in the test/ directory. The entry point is test/<your_package>_test.gleam, but you can organize tests across multiple files.
Here is a simple module under test:
// src/calculator.gleam
pub fn add(a: Int, b: Int) -> Int {
a + b
}
pub fn divide(a: Float, b: Float) -> Result(Float, String) {
case b {
0.0 -> Error("Division by zero")
_ -> Ok(a /. b)
}
}And the corresponding tests:
// test/calculator_test.gleam
import gleeunit
import gleeunit/should
import calculator
pub fn main() {
gleeunit.main()
}
pub fn add_test() {
calculator.add(2, 3)
|> should.equal(5)
}
pub fn add_negative_test() {
calculator.add(-10, 4)
|> should.equal(-6)
}
pub fn divide_success_test() {
calculator.divide(10.0, 2.0)
|> should.equal(Ok(5.0))
}
pub fn divide_by_zero_test() {
calculator.divide(5.0, 0.0)
|> should.equal(Error("Division by zero"))
}Run them with:
gleam testGleeunit discovers every function ending in _test and runs them all. Failures are reported with the expected and actual values clearly labeled.
The should Assertion API
The gleeunit/should module provides a set of composable assertions that work well with Gleam's pipe operator |>:
import gleeunit/should
// Equality
value |> should.equal(expected)
value |> should.not_equal(unexpected)
// Boolean
condition |> should.be_true
condition |> should.be_false
// Option and Result
option_value |> should.be_some
option_value |> should.be_none
result_value |> should.be_ok
result_value |> should.be_errorThe pipe-first style reads naturally: "this value should equal that." It keeps test lines short and the intent obvious.
Testing With Custom Types
Gleam's type system shines when you model your domain with custom types. Tests for custom types follow the same pattern, but you often need pattern matching to inspect variants:
// src/user.gleam
pub type Role {
Admin
Member
Guest
}
pub type User {
User(name: String, role: Role)
}
pub fn can_delete(user: User) -> Bool {
case user.role {
Admin -> True
Member | Guest -> False
}
}// test/user_test.gleam
import gleeunit/should
import user.{Admin, Guest, Member, User}
pub fn admin_can_delete_test() {
User(name: "Alice", role: Admin)
|> user.can_delete
|> should.be_true
}
pub fn guest_cannot_delete_test() {
User(name: "Bob", role: Guest)
|> user.can_delete
|> should.be_false
}
pub fn member_cannot_delete_test() {
User(name: "Carol", role: Member)
|> user.can_delete
|> should.be_false
}Each variant gets its own test. This gives you clear failure messages and makes it obvious when a role's permissions change.
Testing Gleam on the JavaScript Target
Gleam compiles to both Erlang and JavaScript. If your package targets JavaScript (for use in Node or the browser), you can run tests against the JS target:
gleam test --target javascriptThis is valuable if your code uses target-specific imports or you need to verify behavior in both runtimes. Most pure Gleam code behaves identically on both targets, but external function calls (@external) can differ.
Organizing Tests for Larger Projects
As your project grows, split tests across files that mirror your source structure:
src/
auth/
jwt.gleam
session.gleam
payments/
stripe.gleam
test/
auth/
jwt_test.gleam
session_test.gleam
payments/
stripe_test.gleamEach test file imports its corresponding source module. Gleeunit picks up all _test functions across the entire test/ tree automatically — no registration required.
Testing Error Paths Thoroughly
Gleam's Result type encourages you to model failures explicitly. This makes error path testing straightforward:
// src/parser.gleam
pub fn parse_age(input: String) -> Result(Int, String) {
case int.parse(input) {
Ok(n) if n >= 0 && n <= 150 -> Ok(n)
Ok(_) -> Error("Age out of range")
Error(_) -> Error("Not a valid number")
}
}pub fn parse_valid_age_test() {
parser.parse_age("25")
|> should.equal(Ok(25))
}
pub fn parse_negative_age_test() {
parser.parse_age("-1")
|> should.equal(Error("Age out of range"))
}
pub fn parse_too_old_test() {
parser.parse_age("200")
|> should.equal(Error("Age out of range"))
}
pub fn parse_garbage_test() {
parser.parse_age("abc")
|> should.equal(Error("Not a valid number"))
}Every branch of the case expression gets a dedicated test. When you revisit this logic months later, the tests document exactly what each branch does.
CI Integration
Running gleam test in CI is straightforward. Here is a GitHub Actions workflow:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Gleam
uses: erlef/setup-beam@v1
with:
otp-version: "26"
gleam-version: "1.3.0"
- name: Run tests
run: gleam test
- name: Run tests (JS target)
run: gleam test --target javascriptThe setup-beam action handles both Erlang/OTP and Gleam installation. Pin your Gleam version to avoid surprise breakage on minor releases.
What Gleeunit Does Not Cover
Gleeunit is intentionally minimal. It does not include:
- Property-based testing — for that, look at community libraries or port concepts from QuickCheck-style libraries in other BEAM languages
- Mocking — Gleam's functional style discourages mutable state, so mocking is rarely needed; prefer dependency injection via function arguments
- Snapshot testing — not yet available in the ecosystem; encode your expected values explicitly in assertions
- Async/concurrent test helpers — the Erlang VM handles concurrency well, but gleeunit's API is synchronous
For end-to-end testing of web applications built with Gleam (such as Lustre or Wisp apps), tools like HelpMeTest can run automated browser tests against your deployed app without requiring you to write a line of browser automation code. At $100/month for the Pro plan, it covers the full QA surface that unit tests cannot reach.
Common Pitfalls
Test function not discovered. Make sure the function is pub and ends in _test. Private functions are never discovered.
Import errors in test files. The test/ directory is a separate package context. You import your source package by its name (as defined in gleam.toml), not by relative path.
Floating-point equality. Avoid should.equal for floats that involve arithmetic. Gleam inherits IEEE 754 from Erlang/JS, so 0.1 +. 0.2 does not equal 0.3. Use a tolerance check instead.
Slow tests on the JS target. The Node.js startup overhead is noticeable for large test suites. Keep the JS target run separate from the Erlang run in CI so failures are easy to distinguish.
Summary
Gleeunit gives you a zero-configuration testing experience that fits Gleam's philosophy: simple, explicit, and honest about what it does. The should assertion API is ergonomic, test discovery is automatic, and the integration with gleam test means there is no tooling to configure.
Write one test per behavior, name tests after what they verify, and cover every branch of every case expression. The Gleam type system will catch a large class of bugs at compile time, but tests remain the only way to verify that your logic is correct for real inputs. Set up CI from day one, and consider pairing gleeunit with a cloud testing platform for your deployed services so your functional core and your live application stay in sync.