Test Timeouts in Gleam

25 November, 2025

If you don't care about the backstory and just want to know the answer, you can skip there.

How Gleam tests work

When you create a new Gleam project, there are two dependencies that are added automatically by the build tool. These are gleam_stdlib, the Gleam standard library, and gleeunit, the default test runner for Gleam. Both of these dependencies are technically optional, but they are added by default to make it easier to start a new project, since most projects use them.

See, when you run gleam test in the terminal, while it may seem like the Gleam build tool finds all your test functions magically, all it's really doing is calling the main function of the module named $PROJECT_test, which by default calls out to gleeunit, the code that's really in charge of running your tests.

What is gleeunit?

On the Erlang target, the gleeunit library is a thin wrapper for EUnit, which is a test runner built into OTP. On JavaScript, gleeunit implements a custom test runner that has similar (but not identical) features to EUnit. Somewhat unfortunately, EUnit comes with a default timeout of 5 seconds, and seemingly no way to globally adjust it, so if any test runs for longer than that it is immediately terminated.

EUnit has a somewhat unconventional API. As the default Gleam project explains, it runs all public functions whose names end in _test. But there's a second more obscure API, in the form of test generators.

Test generators are another kind of EUnit test. Test generator functions end in _test_ (note the trailing underscore), and they have the ability to configure various parts of how EUnit runs. It's detailed in the EUnit documentation, but here we will just cover the most commonly used one, which is the timeout.

How do I configure my test timeout?

EUnit test generators allow returning extra values along with a function to call in order to configure it. In Erlang syntax, here is how you would configure the timeout for your test, to be 60 seconds instead of 5:

something_test_() ->
  {timeout, 60, fun () -> 
    ... % The actual body of your test 
  }.

So, what does this translate to Gleam? Well, there are actually two ways to do it. The first is to use the atom.create function from the gleam_erlang library. This creates basically the same as the Erlang code:

import gleam/erlang/atom

pub fn something_test_() {
  #(atom.create("timeout"), 60, fn() {
    ... // The actual body of your test
  })
}

This works, but it has a couple of drawbacks. Firstly, you need to pull in a new library just to create this one atom, and secondly it just doesn't look very nice. My preferred solution takes advantage of the way that custom types are represented in Gleam.

When Gleam compiles to Erlang, Gleam custom types become an Erlang tuple tagged with an atom. So the Gleam value Ok(10) becomes the Erlang {ok, 10}. This is perfect for what we need, because it's the exact same format as what EUnit expects. Using that knowledge we can define a custom type to represent the timeout tuple:

pub type Timeout(a) {
  Timeout(time: Int, function: fn() -> a)
}

You can use Float instead for time here, but I find I rarely need to set my timeout to a fraction of a second, so Int is more convenient. Now, we can take advantage of Gleam's use syntax, which allows us to turn a call to a higher order function — where we pass an anonymous function, like we did in the previous example — into a flattened statement. We now get a much nicer looking test:

pub type Timeout(a) {
  Timeout(time: Int, function: fn() -> a)
}

pub fn something_test_() {
  use <- Timeout(60)
  ... // The actual body of your test
}

Great! We've successfully worked around EUnit's annoying timeout. Job done! But wait...

Multi-target tests

Remember when I mentioned earlier about how gleeunit works differently on the JavaScript target? Well, the good news is that it doesn't have a timeout like on the Erlang target, but the bad news is that it doesn't support test generators. So if all your tests are called *_test_, none of them are going to run on the JavaScript target.

The only real way to get around this is to write a bit of extra boilerplate for all of your tests. You need to write a test that runs only on JavaScript, that calls the same code as the Erlang test. There's only really one way achieve this, with the @target attribute, and it's not pretty. Here's an example:

// This runs on Erlang
pub fn something_test_() {
  use <- Timeout(60)
  ... // The actual body of your test
}

// This runs on JavaScript
@target(javascript)
pub fn something_test() {
  something_test_().function()
}

The @target attribute is deprecated, and you shouldn't use it really, but there isn't any alternative for this specific case. Hopefully soon we will have an alternative, both to @target, and to this problem of different behaviour in the test runner across targets.

Conclusion

If you want to change the timeout of a test that only runs on Erlang, it's not very hard. If you want to have tests that run on both targets for longer than 5 seconds, it gets messy. Hopefully there will be a better way to do it in the near future.