Skip to main content

Getting started

Install

LPM is a package manager for the Luau ecosystem that also pins tools. It records Lest's version per project — in lpm.toml, shared with everyone who clones the repo — and puts it on your PATH:

$ lpm tool add lest
$ lpm i

With rokit

rokit works the same way, pinning the version in rokit.toml:

$ rokit add lest-luau/lest

Either way, a teammate cloning the project runs one install command and gets the same Lest — and lune/lute for the spawned-runtime backends pin alongside it in the same file.

From source

With Rust:

$ git clone https://github.com/lest-luau/lest
$ cd lest
$ cargo build --release
$ ./target/release/lest self install

lest self install copies the binary into ~/.lest/bin and adds that directory to your PATH. lest self uninstall reverses both. If you'd rather manage the binary yourself, skip it and put target/release/lest wherever you like.

Building from source is also how you get an unreleased build — the main branch, ahead of the latest tagged release.

Verify

$ lest --version

Scaffold a project

$ lest init

lest init detects what it can before asking anything — whether a rojo project file is present, whether lune or lute are on your PATH, whether you already have spec files — then asks only about what's left: the default backend, the main suite's name, whether to add a runtime-scripts suite and an opt-in cloud engine suite, the main suite's include glob, whether to add a lest alias to your .luaurc so specs can require('@lest') from anywhere, whether to write an example spec, and whether to add /.lest to your .gitignore.

The extra suites come before the main glob on purpose: if you add one (or a rojo project was detected), the main include is pre-filled as src/**/*.spec.luau so it stays out of tests/scripts/ and tests/engine/, and a glob that would still reach an extra suite's directory is rejected with an explanation — a spec matched by two suites runs in both. Globs use / as the separator; backslashes are rejected.

Add --yes (or -y) to accept every default without prompting, which is what you want in a script or a container; --no-color disables colored prompts.

It's safe to re-run. If a lest.toml already exists, init asks A lest.toml already exists. Overwrite it? — answering yes replaces the file wholesale, answering no leaves it untouched. With --yes it refuses to overwrite and exits with code 2.

Four things land in your project:

lest.toml your suites and settings
example.spec.luau a working spec, if you asked for one
.luaurc a `lest` alias, if you accepted it
.lest/ generated; add it to .gitignore

About .lest/

.lest/ is Lest's scratch directory. It holds core — the framework itself, written out of the binary on demand — plus the generated harness scripts the Lune and Lute backends run. It is entirely reproducible: delete it and the next lest run puts it back. Don't commit it, and don't edit anything in it.

Write a spec

A spec is any file matched by a suite's include globs — by convention, *.spec.luau beside the code it tests.

--!strict
local Lest = require('@lest')
local describe, it, expect = Lest.describe, Lest.it, Lest.expect

local cart = require('./cart')

describe('cart', function ()
it('sums line items', function ()
expect(cart.total({ 3, 4 })).toBe(7)
end)
end)

return nil

Three things to notice:

  • require('@lest') works because lest init wrote that alias into .luaurc. In a project configured with .config.luau, init prints the alias line to paste instead (aliases resolve from either format) — and until you paste it, the generated example requires core by relative path. If you declined the alias, require the framework by path instead — require('../.lest/core'), adjusted for where the spec sits.
  • The destructuring line (local describe, it, expect = ...) is deliberate. Lest has no ambient globals: your specs require real .luau files, so Luau-LSP infers types from the implementation and selene needs no configuration beyond std = "luau".
  • return nil at the end. Spec files are modules, and Luau modules return a value. Lest collects tests through registration, not through what the file returns.

Run it

$ lest

Bare lest runs every default suite. From there:

$ lest run unit # a single suite by name
$ lest --watch # re-run affected specs on save
$ lest -t 'sums' # only tests whose full name contains "sums"
$ lest --coverage # add a line-coverage report
$ lest -u # update snapshots that differ

The full surface is in the CLI reference.

Where to go next