Reflaxe.Elixir provides comprehensive support for writing ExUnit tests in Haxe that compile to idiomatic Elixir test modules. This guide covers all supported ExUnit features and how to use them effectively.
ExUnit is Elixir's built-in testing framework. With Reflaxe.Elixir, you can write type-safe tests in Haxe that compile to proper ExUnit test modules, complete with all the features you'd expect:
- Test functions with descriptive names
- Setup and teardown callbacks
- Describe blocks for grouping related tests
- Async tests for concurrent execution
- Test tagging for selective execution
- All ExUnit assertions with type safety
To create an ExUnit test module, extend TestCase and mark your class with @:exunit:
import exunit.TestCase;
import exunit.Assert.*;
@:exunit
class UserTest extends TestCase {
@:test
function testUserCreation(): Void {
var user = new User("Alice", 30);
assertEqual("Alice", user.name);
assertEqual(30, user.age);
}
}This compiles to:
defmodule UserTest do
use ExUnit.Case
test "user creation" do
user = User.new("Alice", 30)
assert user.name == "Alice"
assert user.age == 30
end
endPurpose: Identifies a class as an ExUnit test module.
Why: Tells the compiler to generate use ExUnit.Case and transform test methods into ExUnit test blocks.
How to use:
@:exunit
class MyTest extends TestCase {
// Test methods here
}Purpose: Identifies a method as a test case.
Why: These methods become test blocks in the generated ExUnit module.
How to use:
@:test
function testSomething(): Void {
assertTrue(true);
}Name transformation: The compiler automatically removes "test" prefix and converts to readable names:
testUserLogin→test "user login"testCreateOrder→test "create order"validateEmail→test "validate email"
Purpose: Groups related tests in a describe block for better organization.
Why: Improves test readability and allows running specific groups of tests.
How to use:
@:describe("User validation")
@:test
function testEmailValidation(): Void {
assertTrue(User.isValidEmail("test@example.com"));
}
@:describe("User validation")
@:test
function testAgeValidation(): Void {
assertTrue(User.isValidAge(25));
}Compiles to:
describe "User validation" do
test "email validation" do
assert User.is_valid_email("test@example.com")
end
test "age validation" do
assert User.is_valid_age(25)
end
endPurpose: Marks tests to run concurrently with other async tests.
Why: Speeds up test suite execution for tests that don't share state.
How to use:
@:async
@:test
function testIndependentOperation(): Void {
// This test can run in parallel with other async tests
var result = performOperation();
assertNotNull(result);
}Note: When any test in a module is marked @:async, the entire module uses use ExUnit.Case, async: true.
Purpose: Tags tests for conditional execution or filtering.
Why: Allows running specific subsets of tests (e.g., skip slow tests in CI).
How to use:
@:tag("slow")
@:test
function testDatabaseMigration(): Void {
// This test might take a while
Database.runMigrations();
assertTrue(Database.isReady());
}
@:tag("integration")
@:tag("external")
@:test
function testExternalAPI(): Void {
// Test that requires external service
var response = API.fetchData();
assertNotNull(response);
}Run tagged tests with:
mix test --only slow
mix test --exclude integrationPurpose: Executes code before each test in the module.
Why: Prepares test fixtures and ensures clean state for each test.
How to use:
@:setup
function setupDatabase(): Void {
Database.beginTransaction();
insertTestData();
}Purpose: Executes code once before any tests in the module run.
Why: Performs expensive one-time setup like starting external services.
How to use:
@:setupAll
function startServices(): Void {
TestServer.start();
Database.createTestDatabase();
}Purpose: Executes cleanup code after each test.
Why: Ensures tests don't affect each other by cleaning up state.
How to use:
@:teardown
function cleanupDatabase(): Void {
Database.rollbackTransaction();
clearTempFiles();
}Purpose: Executes cleanup code once after all tests complete.
Why: Cleans up expensive resources created in setupAll.
How to use:
@:teardownAll
function stopServices(): Void {
TestServer.stop();
Database.dropTestDatabase();
}Here's a comprehensive example using all features:
import exunit.TestCase;
import exunit.Assert.*;
@:exunit
class TodoAppTest extends TestCase {
// One-time setup for all tests
@:setupAll
function startApp(): Void {
TodoApp.start();
Database.migrate();
}
// Setup before each test
@:setup
function beginTransaction(): Void {
Database.beginTransaction();
insertSampleTodos();
}
// Tests for Todo CRUD operations
@:describe("Todo CRUD")
@:test
function testCreateTodo(): Void {
var todo = Todo.create("Buy milk", false);
assertNotNull(todo.id);
assertEqual("Buy milk", todo.title);
assertFalse(todo.completed);
}
@:describe("Todo CRUD")
@:test
function testUpdateTodo(): Void {
var todo = Todo.create("Buy milk", false);
todo.complete();
assertTrue(todo.completed);
}
@:describe("Todo CRUD")
@:test
function testDeleteTodo(): Void {
var todo = Todo.create("Buy milk", false);
var id = todo.id;
todo.delete();
assertNull(Todo.find(id));
}
// Tests for Todo filtering
@:describe("Todo filtering")
@:test
function testFilterCompleted(): Void {
createMixedTodos();
var completed = Todo.filterCompleted();
assertEqual(3, completed.length);
}
@:describe("Todo filtering")
@:async
@:test
function testFilterByUser(): Void {
var userTodos = Todo.filterByUser("alice");
assertTrue(userTodos.length > 0);
}
// Integration tests (can be excluded)
@:tag("integration")
@:test
function testSyncWithServer(): Void {
var todos = Todo.all();
var result = TodoSync.uploadToServer(todos);
assertTrue(result.success);
}
// Slow tests (can be excluded in CI)
@:tag("slow")
@:tag("database")
@:test
function testLargeBatchInsert(): Void {
var todos = generateLargeBatch(10000);
Todo.batchInsert(todos);
assertEqual(10000, Todo.count());
}
// Cleanup after each test
@:teardown
function rollbackTransaction(): Void {
Database.rollbackTransaction();
}
// Final cleanup
@:teardownAll
function stopApp(): Void {
TodoApp.stop();
Database.cleanupTestData();
}
// Helper methods (not tests)
function insertSampleTodos(): Void {
Todo.create("Sample 1", false);
Todo.create("Sample 2", true);
}
function createMixedTodos(): Void {
for (i in 0...5) {
Todo.create('Todo $i', i < 3);
}
}
function generateLargeBatch(count: Int): Array<Todo> {
return [for (i in 0...count) new Todo('Batch todo $i', false)];
}
}ExUnit's assertion system poses a unique challenge: assert, refute, and other ExUnit assertions are macros that must be expanded at compile-time in the test module's context. They cannot be wrapped in regular functions in another module.
The Problem:
// This approach DOESN'T work - generates wrong code
class Assert {
public static function assertEqual<T>(expected: T, actual: T): Void {
untyped __elixir__('assert {0} == {1}', actual, expected);
}
}
// Generates: Assert.assert_equal(5, result) // WRONG - assert isn't available!The Solution - Extern Inline:
// This approach WORKS - inlines at call site
class Assert {
extern inline public static function assertEqual<T>(expected: T, actual: T): Void {
untyped __elixir__('assert {0} == {1}', actual, expected);
}
}
// Generates: assert result == 5 // CORRECT - macro expanded in test context!extern- Tells Haxe this function has no body to compileinline- Forces the function body to be copied to the call site- Result - The
__elixir__()code is injected directly where called
This means when you write:
assertEqual(5, calculateSum(2, 3));It compiles to:
assert calculate_sum(2, 3) == 5Not to:
Assert.assert_equal(5, calculate_sum(2, 3)) # This wouldn't work!The exunit.Assert class provides type-safe assertion methods (all using extern inline):
| Method | Purpose | Example |
|---|---|---|
assertEqual(expected, actual) |
Assert two values are equal | assertEqual(5, 2 + 3) |
assertNotEqual(expected, actual) |
Assert two values are not equal | assertNotEqual(5, 2 + 2) |
assertTrue(condition) |
Assert condition is true | assertTrue(user.isActive) |
assertFalse(condition) |
Assert condition is false | assertFalse(list.isEmpty()) |
assertNull(value) |
Assert value is null/nil | assertNull(user.deletedAt) |
assertNotNull(value) |
Assert value is not null/nil | assertNotNull(user.id) |
assertRaises(fn) |
Assert function raises exception | assertRaises(() -> divide(1, 0)) |
fail(message) |
Fail test with message | fail("Should not reach here") |
For functional types like Result<T,E> and Option<T>:
| Method | Purpose | Example |
|---|---|---|
assertIsOk(result) |
Assert Result is Ok | assertIsOk(Email.parse("test@example.com")) |
assertIsError(result) |
Assert Result is Error | assertIsError(Email.parse("invalid")) |
assertIsSome(option) |
Assert Option is Some | assertIsSome(findUser(id)) |
assertIsNone(option) |
Assert Option is None | assertIsNone(findUser(-1)) |
These use Elixir's match?/2 macro for pattern matching against tagged tuples.
mix testmix test test/user_test.exsmix test --only describe:"User validation"mix test --only asyncmix test --exclude slowmix test --cover- Use describe blocks to group related tests for better organization
- Mark independent tests as @:async to speed up test execution
- Use tags to categorize tests (unit, integration, slow, etc.)
- Keep setup/teardown focused - only set up what's needed
- Use descriptive test names that explain what's being tested
- One assertion per test when possible for clearer failure messages
- Use helper methods to reduce duplication in test setup
- Ensure class extends
TestCase - Verify
@:exunitannotation is present on the class - Check that test methods have
@:testannotation
- Verify tests don't share mutable state
- Ensure database tests use separate connections/transactions
- Check for race conditions in shared resources
- Verify annotations are spelled correctly (
@:setup, not@:setUp) - Ensure methods are not static
- Check that methods don't have parameters
- Property-based testing with StreamData
- Test.describe with nested contexts
- Parameterized tests
- Custom assertions
- Mocking and stubbing support
Reflaxe.Elixir's ExUnit support provides a complete, type-safe testing experience that compiles to idiomatic ExUnit tests. All the power of ExUnit with the safety and tooling of Haxe!