Skip to content

Commit 1ec7efa

Browse files
Write test timeout article
1 parent dec4b1e commit 1ec7efa

File tree

8 files changed

+337
-46
lines changed

8 files changed

+337
-46
lines changed

blog/gleam-test-timeouts.djot

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
title = "Test timeouts in Gleam"
3+
date = "2025-11-25"
4+
description = "By default, Gleam tests come with a timeout of 5 seconds. It's not as easy as you might think to remove it."
5+
---
6+
7+
If you don't care about the backstory and just want to know the answer, you can
8+
[skip there](#How-do-I-configure-my-test-timeout).
9+
10+
## How Gleam tests work
11+
12+
When you create a new Gleam project, there are two dependencies that are added
13+
automatically by the build tool. These are [`gleam_stdlib`](https://hexdocs.pm/gleam_stdlib),
14+
the Gleam standard library, and [`gleeunit`](https://hexdocs.pm/gleeunit), the
15+
default test runner for Gleam.
16+
17+
Both of these dependencies are technically optional, but they are added by default
18+
to make it easier to start a new project, since most projects use both the above
19+
packages.
20+
21+
See, when you run `gleam test` in the terminal, while it may seem that the Gleam
22+
build tool finds all your test functions magically, all it's really doing is calling
23+
the `main` function of the module named `$PROJECT_test`, which by default calls
24+
out to `gleeunit`, which is the code that's really in charge of running your tests.
25+
26+
## What is gleeunit?
27+
28+
On the Erlang target, the `gleeunit` library is a thin wrapper for
29+
[`EUnit`](https://www.erlang.org/doc/apps/eunit/chapter.html), which is a test
30+
runner built into OTP. On JavaScript, `gleeunit` implements a custom test runner
31+
that has similar (but not identical) features to `EUnit`.
32+
33+
Somewhat unfortunately, EUnit comes with a default timeout of 5 seconds, and
34+
seemingly [no way to globally adjust it](https://github.com/lpil/gleeunit/issues/51)
35+
so if any test runs for longer than that it is immediately terminated.
36+
37+
EUnit has a somewhat unconventional API. As the default Gleam project explains,
38+
it runs all public functions whose names end in `_test`. But there's a second
39+
more obscure API, in the form of *test generators*.
40+
41+
Test generators are another kind of EUnit test. Test generator functions end
42+
in `*_test_` (note the trailing underscore), and they have the ability to configure
43+
various parts of how EUnit runs. It's detailed in the EUnit documentation,
44+
but here we will just cover the most commonly used one, which is the timeout.
45+
46+
## How do I configure my test timeout?
47+
48+
EUnit test generators allow returning extra values along with a function to call
49+
in order to configure it. In Erlang syntax, here is how you would configure the
50+
timeout for your test, to be 60 seconds instead of 5:
51+
52+
```erlang
53+
something_test_() ->
54+
{timeout, 60, fun () ->
55+
... % The actual body of your test
56+
}.
57+
```
58+
59+
So, what does this translate to Gleam? Well, there are actually two ways to do it.
60+
The first is to use the `atom.create` function from the [`gleam_erlang`](https://hexdocs.pm/gleam_erlang)
61+
library. This creates basically the same as the Erlang code:
62+
63+
```gleam
64+
import gleam/erlang/atom
65+
66+
pub fn something_test_() {
67+
#(atom.create("timeout"), 60, fn() {
68+
... // The actual body of your test
69+
})
70+
}
71+
```
72+
73+
This works, but it has a couple of drawbacks. Firstly, you need to pull in a new
74+
library just to create this one atom, and secondly it just doesn't look very nice.
75+
My preferred solution takes advantage of the way that custom types are represented
76+
in Gleam.
77+
78+
When Gleam compiles to Erlang, Gleam custom types become an Erlang tuple tagged
79+
with an atom. So the Gleam value `Ok(10)` becomes the Erlang `{ok, 10}`. This is
80+
perfect for what we need, because it's the exact same format as what EUnit expects.
81+
Using that knowledge we can define a custom type to represent the timeout tuple:
82+
83+
```gleam
84+
pub type Timeout(a) {
85+
Timeout(time: Int, function: fn() -> a)
86+
}
87+
```
88+
89+
You can use `Float` instead for `time` here, but I find I rarely need to set my
90+
timeout to a fraction of a second, so `Int` is more convenient. Now, we can take
91+
advantage of Gleam's [`use` syntax](https://tour.gleam.run/advanced-features/use/),
92+
which allows us to turn a call to a higher order function — where we pass an anonymous
93+
function, like we did in the previous example — into a flattened statement.
94+
We now get a much nicer looking test:
95+
96+
```gleam
97+
pub type Timeout(a) {
98+
Timeout(time: Int, function: fn() -> a)
99+
}
100+
101+
pub fn something_test_() {
102+
use <- Timeout(60)
103+
... // The actual body of your test
104+
}
105+
```
106+
107+
Great! We've successfully worked around EUnit's annoying timeout. Job done! But
108+
wait...
109+
110+
## Multi-target tests
111+
112+
Remember when I mentioned earlier about how `gleeunit` works differently on the
113+
JavaScript target? Well, the good news is that it doesn't have a timeout like on
114+
the Erlang target, but the bad news is that it doesn't support test generators.
115+
So if all your tests are called `*_test_`, none of them are going to run on
116+
the JavaScript target.
117+
118+
The only real way to get around this is to write a bit of extra boilerplate for
119+
all of your tests. You need to write a test that runs only on JavaScript, that
120+
calls the same code as the Erlang test. There's only really one way achieve this,
121+
with the `@target` attribute, and it's not pretty. Here's an example:
122+
123+
```gleam
124+
// This runs on Erlang
125+
pub fn something_test_() {
126+
use <- Timeout(60)
127+
... // The actual body of your test
128+
}
129+
130+
// This runs on JavaScript
131+
@target(javascript)
132+
pub fn something_test() {
133+
something_test_().function()
134+
}
135+
```
136+
137+
The `@target` attribute is deprecated, and you shouldn't use it really, but there
138+
isn't any alternative for this specific case. Hopefully soon we will have an
139+
alternative, both to `@target`, and to this problem of different behaviour in the
140+
test runner across targets.
141+
142+
## Conclusion
143+
144+
If you want to change the timeout of a test that only runs on Erlang, it's not
145+
very hard. If you want to have tests that run on both targets for longer than 5
146+
seconds, it gets messy.
147+
Hopefully there will be a better way to do it in the near future.

blog/test.djot

Lines changed: 0 additions & 7 deletions
This file was deleted.

gleam.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ lustre = ">= 5.0.2 and < 6.0.0"
1919
tom = ">= 1.1.0 and < 2.0.0"
2020
simplifile = ">= 2.2.0 and < 3.0.0"
2121
lustre_ssg = ">= 0.11.0 and < 1.0.0"
22+
jot = ">= 4.0.0 and < 5.0.0"
23+
pearl = ">= 2.2.0 and < 3.0.0"
24+
contour = ">= 1.2.1 and < 2.0.0"
2225

2326
[dev-dependencies]
2427
gleeunit = ">= 1.0.0 and < 2.0.0"

manifest.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,38 @@
22
# You typically do not need to edit this file
33

44
packages = [
5+
{ name = "contour", version = "1.2.1", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "glexer", "houdini"], otp_app = "contour", source = "hex", outer_checksum = "C7895472BBAAC5D15DBDBAF6DD1847F6981F2F6132F726BAD30434A6E3BAAB95" },
56
{ name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
67
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
78
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
9+
{ name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" },
10+
{ name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" },
811
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
912
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
1013
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
1114
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
1215
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
1316
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
1417
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
18+
{ name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" },
1519
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
1620
{ name = "jot", version = "4.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "houdini", "splitter"], otp_app = "jot", source = "hex", outer_checksum = "E9E266D2768EA1238283D2CF125AA68095F17BAA4DDF3598360FD19F38593C59" },
1721
{ name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" },
1822
{ name = "lustre_ssg", version = "0.11.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_regexp", "gleam_stdlib", "jot", "lustre", "simplifile", "temporary", "tom"], otp_app = "lustre_ssg", source = "hex", outer_checksum = "D1F2B47EBE27C2B1DE6552A0883BC676D8EB076359D152FFA323FADDC74FFC41" },
23+
{ name = "pearl", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_community_ansi", "gleam_stdlib", "houdini", "splitter"], otp_app = "pearl", source = "hex", outer_checksum = "E5E14CE725542BA2516570D04140F0D1471761CCE27C6579C6396369FFF1DA9F" },
1924
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
2025
{ name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" },
2126
{ name = "temporary", version = "1.0.0", build_tools = ["gleam"], requirements = ["envoy", "exception", "filepath", "gleam_crypto", "gleam_stdlib", "simplifile"], otp_app = "temporary", source = "hex", outer_checksum = "51C0FEF4D72CE7CA507BD188B21C1F00695B3D5B09D7DFE38240BFD3A8E1E9B3" },
2227
{ name = "tom", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "tom", source = "hex", outer_checksum = "0910EE688A713994515ACAF1F486A4F05752E585B9E3209D8F35A85B234C2719" },
2328
]
2429

2530
[requirements]
31+
contour = { version = ">= 1.2.1 and < 2.0.0" }
2632
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
2733
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
34+
jot = { version = ">= 4.0.0 and < 5.0.0" }
2835
lustre = { version = ">= 5.0.2 and < 6.0.0" }
2936
lustre_ssg = { version = ">= 0.11.0 and < 1.0.0" }
37+
pearl = { version = ">= 2.2.0 and < 3.0.0" }
3038
simplifile = { version = ">= 2.2.0 and < 3.0.0" }
3139
tom = { version = ">= 1.1.0 and < 2.0.0" }

src/website/component.gleam

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ pub fn text_page(header: String, content: List(Element(a))) -> Section(a) {
6060
[html.text(header)],
6161
),
6262
]),
63-
html.div([attribute.class("mx-auto max-w-4xl py-8 leading-8")], [
64-
html.p([attribute.class("text-xl")], content),
65-
]),
63+
html.div([attribute.class("mx-auto max-w-4xl py-8 leading-8")], content),
6664
])
6765
}
6866

src/website/data/blog.gleam

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1+
import contour
12
import gleam/bool
23
import gleam/dict.{type Dict}
34
import gleam/list
5+
import gleam/option
46
import gleam/string
7+
import jot
8+
import lustre/attribute.{attribute}
59
import lustre/element
10+
import lustre/element/html
611
import lustre/ssg/djot
12+
import pearl
713
import simplifile
814
import tom.{type Toml}
15+
import website/component
916

1017
pub fn posts() -> List(Post(a)) {
1118
let assert Ok(files) = simplifile.read_directory("blog/")
@@ -16,7 +23,7 @@ pub fn posts() -> List(Post(a)) {
1623
let assert Ok(contents) = simplifile.read(file_path)
1724

1825
let assert Ok(metadata) = djot.metadata(contents)
19-
let contents = djot.render(contents, djot.default_renderer())
26+
let contents = djot.render(contents, renderer())
2027

2128
let title = get_string_key(metadata, "title")
2229
let description = get_string_key(metadata, "description")
@@ -28,6 +35,71 @@ pub fn posts() -> List(Post(a)) {
2835
})
2936
}
3037

38+
fn renderer() -> djot.Renderer(element.Element(a)) {
39+
let default = djot.default_renderer()
40+
djot.Renderer(
41+
..default,
42+
link: fn(destination, references, content) {
43+
case destination {
44+
jot.Reference(ref) ->
45+
case dict.get(references, ref) {
46+
Ok(url) ->
47+
html.a(
48+
[
49+
attribute.href(url),
50+
attribute.target(target(url)),
51+
attribute.class("underline"),
52+
],
53+
content,
54+
)
55+
Error(_) ->
56+
html.a(
57+
[
58+
attribute.href("#" <> ref),
59+
attribute.id("back-to-" <> ref),
60+
],
61+
content,
62+
)
63+
}
64+
jot.Url(url) ->
65+
html.a(
66+
[
67+
attribute.href(url),
68+
attribute.target(target(url)),
69+
attribute.class("underline"),
70+
],
71+
content,
72+
)
73+
}
74+
},
75+
codeblock: fn(attrs, lang, code) {
76+
let lang = option.unwrap(lang, "text")
77+
78+
let code = case lang {
79+
"erlang" | "erl" -> component.dangerous_html(pearl.highlight_html(code))
80+
"gleam" -> component.dangerous_html(contour.to_html(code))
81+
_ -> html.text(code)
82+
}
83+
84+
html.pre(
85+
dict.fold(attrs, [], fn(attrs, name, value) {
86+
[attribute(name, value), ..attrs]
87+
}),
88+
[
89+
html.code([attribute("data-lang", lang)], [code]),
90+
],
91+
)
92+
},
93+
)
94+
}
95+
96+
fn target(url: String) -> String {
97+
case url {
98+
"https://" <> _ | "http://" <> _ -> "_blank"
99+
_ -> ""
100+
}
101+
}
102+
31103
fn get_string_key(toml: Dict(String, Toml), key: String) -> String {
32104
let assert Ok(value) = dict.get(toml, key)
33105
let assert tom.String(value) = value

0 commit comments

Comments
 (0)