Modules and Imports
The previous articles built up everything you need to test programs thoroughly, but every example so far has lived in a single file. As a test suite grows, you end up with the same helper functions and effect definitions duplicated across test files. Change the startup sequence for a service, and you are editing the same code in five different places.
Relux solves this with modules and imports. Every .relux file is a module. You put shared code in the lib/ directory, and test files import what they need.
Here is a library module at lib/utils/greeter.relux:
fn greet(name) {
> echo "hello ${name}"
<? ^hello ${name}$
match_prompt()
}
fn farewell(name) {
> echo "goodbye ${name}"
<? ^goodbye ${name}$
match_prompt()
}
effect Greeter {
expose service
shell service {
> export GREETER_STATUS=running
match_ok()
}
}
Two functions and an effect. Now a test file can pull in what it needs:
import utils/greeter
test "say hello" {
shell s {
greet("alice")
}
}
The import line brings everything from the library module into scope. Change greet in one place, and every test that imports it picks up the change.
Every file is a module
A .relux file is automatically a module. There is no special declaration — the file’s path relative to the project root determines its module identity.
A file at lib/utils/greeter.relux has the module path utils/greeter. The .relux extension and the lib/ prefix are stripped; what remains is the module path you use in import statements.
Project structure
A Relux project has two top-level directories under the project root (where Relux.toml lives):
lib/— shared modules containing functions, pure functions, and effects. These are never run directly as tests.tests/— test files. Each.reluxfile here is discovered and executed byrelux run.
Import paths always resolve from lib/. When you write import utils/greeter, Relux looks for lib/utils/greeter.relux. It does not matter where the importing file is — a test at tests/deep/nested/test.relux still imports utils/greeter the same way. This keeps import statements consistent across the entire project: the same module path always means the same file.
Selective imports
The most explicit form of import names exactly which items you want from a module:
import utils/greeter { greet }
This brings the greet function into scope. The module utils/greeter may export other things — in this case it also defines farewell — but only greet is available in this file. Calling farewell would be an error.
You can import multiple items from the same module by listing them — both functions and effects:
import utils/greeter { greet, StartGreeter }
This pulls in the greet function and the StartGreeter effect. Functions use snake_case names, effects use CamelCase — the naming convention is how Relux (and the reader) can tell them apart at a glance.
Trailing commas are allowed:
import utils/greeter {
greet,
StartGreeter,
}
Wildcard imports
If you want everything a module exports, leave out the braces:
import utils/greeter
This brings all exported names into scope — both greet and farewell, as well as the Greeter effect in this case:
import utils/greeter
test "wildcard import makes all functions available" {
start Greeter
shell s {
greet("world")
farewell("world")
}
}
Wildcard imports are convenient for small, focused modules where you know you want everything. For larger modules, selective imports make the dependencies clearer.
Aliases
Sometimes an imported name collides with something in your file, or you simply want a shorter or more descriptive name. The as keyword renames an import:
import utils/greeter { greet as hello, farewell as bye }
Now hello and bye are the callable names — the originals greet and farewell are not in scope. Aliases work for effects too:
import utils/greeter { Greeter as Svc }
test "aliased effect" {
start Svc as svc
shell svc.service {
> echo $$GREETER_STATUS
<? ^running$
}
}
There is one rule: aliases must preserve casing kind. A snake_case function must be aliased to another snake_case name. A CamelCase effect must be aliased to another CamelCase name. Aliasing greet as Hello or Greeter as greeter is a compile error — the casing convention is structural, not cosmetic.
What gets exported
A module exports everything it defines:
- All
fndefinitions - All
pure fndefinitions - All
effectdefinitions
Test definitions are not exported. A test block is local to the file it appears in — you cannot import a test from another module.
There is no visibility modifier. If a function exists in a module, it is exported. If you do not want something exported, the only option is to not put it in a shared lib/ module — though in practice this is rarely a concern. Functions in library modules are there to be shared.
Try it yourself
-
Create a library module at
lib/helpers.reluxwith two functions:check_running()that echoes “running” and matches it, andcheck_stopped()that echoes “stopped” and matches it. Add an effectStartWorkerthat exports a shell and sets an environment variableWORKER_STATUS=active. -
Write a test file
tests/selective_test.reluxthat selectively imports onlycheck_runningandStartWorker. Write one test that callscheck_running()in a shell, and another that needsStartWorkerand verifies the environment variable. -
Write a second test file
tests/wildcard_test.reluxthat uses a wildcard import from the same module. Write a test that uses bothcheck_running()andcheck_stopped(). -
In a third test file, import
check_running as verify_upandStartWorker as Worker. Write a test that uses both under their aliased names.
Next: Condition Markers — conditionally skipping or running tests based on environment