# TestEZ TestEZ is a BDD-style testing framework designed for Roblox Lua, enabling behavior-driven development with a syntax inspired by Mocha, RSpec, and Chai. Developed and used by Roblox for testing apps, in-game core scripts, Studio plugins, and libraries like Roact and Rodux, it provides a robust solution for unit and integration testing in the Roblox ecosystem. TestEZ can run within Roblox itself or on CI systems using Lemur, making it versatile for both development and automated testing workflows. The framework follows a four-stage testing pipeline: loading test modules, creating test plans, executing tests, and reporting results. This architecture eliminates global state and provides multiple abstraction layers, allowing developers to run all tests with a single method call or access granular APIs for custom workflows. TestEZ supports `.spec.lua` files that return functions describing tests using `describe`, `it`, and `expect` functions, which are automatically injected into the test environment. ## Running Tests with TestBootstrap **TestBootstrap:run** executes all tests from specified root locations and reports results. ```lua local TestEZ = require(game.ReplicatedStorage.TestEZ) -- Run tests from multiple locations local results = TestEZ.TestBootstrap:run( { game.ReplicatedStorage.Tests, game.ServerScriptService.Tests }, TestEZ.Reporters.TextReporter, { showTimingInfo = true, testNamePattern = "Addition", extraEnvironment = {} } ) -- Check if all tests passed if results.failureCount == 0 then print("All tests passed!") else print(string.format("Failed: %d tests", results.failureCount)) end ``` ## Basic Test Structure with describe and it **describe** creates test blocks for organizing related tests, and **it** defines individual test cases. ```lua -- Greeter.spec.lua return function() local Greeter = require(script.Parent.Greeter) describe("Greeter:greet", function() it("should include the customary English greeting", function() local greeting = Greeter:greet("World") expect(greeting:match("Hello")).to.be.ok() end) it("should include the person being greeted", function() local greeting = Greeter:greet("Alice") expect(greeting:match("Alice")).to.be.ok() end) it("should return a string", function() local greeting = Greeter:greet("Bob") expect(greeting).to.be.a("string") end) end) end ``` ## Equality and Type Assertions with expect **expect** creates expectations for testing values with readable assertions. ```lua return function() describe("expect assertions", function() it("should test equality", function() expect(1 + 1).to.equal(2) expect("hello").to.equal("hello") expect(true).never.to.equal(false) end) it("should test types", function() expect(42).to.be.a("number") expect("text").to.be.a("string") expect({}).to.be.a("table") expect(newproxy(true)).to.be.a("userdata") end) it("should test nil values", function() expect(1).to.be.ok() expect(false).to.be.ok() expect(nil).never.to.be.ok() end) end) end ``` ## Approximate Equality with near **expect.near** tests numeric values with approximate equality within a tolerance. ```lua return function() describe("Math operations", function() it("should handle floating point precision", function() local result = 0.1 + 0.2 expect(result).to.be.near(0.3) expect(result).to.be.near(0.3, 1e-10) end) it("should test approximate values with custom limits", function() expect(math.pi).to.be.near(3.14159, 0.00001) expect(math.pi).never.to.be.near(3, 0.1) end) it("should handle large numbers", function() local calculated = 1000000.1 expect(calculated).to.be.near(1000000, 0.2) end) end) end ``` ## Error Testing with throw **expect.throw** verifies that functions throw errors with optional message matching. ```lua return function() describe("Error handling", function() it("should catch errors", function() expect(function() error("Something went wrong") end).to.throw() end) it("should verify error messages", function() expect(function() error("Invalid argument: expected number") end).to.throw("Invalid argument") end) it("should verify functions succeed", function() expect(function() return 1 + 1 end).never.to.throw() end) it("should differentiate error messages", function() expect(function() error("Type mismatch") end).never.to.throw("Nil value") end) end) end ``` ## Setup and Teardown with beforeEach and afterEach **beforeEach** runs setup code before each test, and **afterEach** runs cleanup after each test. ```lua return function() local testState beforeEach(function() testState = { counter = 0, data = {} } end) afterEach(function() testState = nil end) describe("State management", function() it("should start with fresh state", function() expect(testState.counter).to.equal(0) end) it("should not share state between tests", function() testState.counter = 10 expect(testState.counter).to.equal(10) end) it("should have isolated state", function() expect(testState.counter).to.equal(0) end) end) end ``` ## One-Time Setup with beforeAll and afterAll **beforeAll** runs once before all tests in a scope, and **afterAll** runs once after all tests complete. ```lua return function() local database local DEFAULT_CONFIG = { timeout = 30 } beforeAll(function() database = { users = {}, config = DEFAULT_CONFIG } database.users["admin"] = { role = "admin" } end) afterAll(function() database = nil end) describe("Database operations", function() it("should have initial admin user", function() expect(database.users["admin"]).to.be.ok() expect(database.users["admin"].role).to.equal("admin") end) it("should allow adding users", function() database.users["john"] = { role = "user" } expect(database.users["john"]).to.be.ok() end) end) end ``` ## Context Sharing Between Hooks and Tests **context** allows sharing data from lifecycle hooks to tests within the same describe block. ```lua -- init.spec.lua return function() beforeAll(function(context) context.helpers = require(script.Parent.helpers) context.testData = { id = 123, name = "Test User" } end) beforeEach(function(context) context.tempStorage = {} end) end -- userTests.spec.lua return function() describe("User operations", function() it("should use shared helpers", function(context) local user = context.helpers.makeUser(context.testData.id) expect(user.id).to.equal(123) context.tempStorage.result = user expect(context.tempStorage.result).to.be.ok() end) it("should access test data", function(context) expect(context.testData.name).to.equal("Test User") end) end) end ``` ## Focused Tests with FOCUS and SKIP **FOCUS** marks tests to run exclusively, and **SKIP** marks tests to skip during execution. ```lua return function() describe("Feature X", function() FOCUS() it("should be focused and run", function() expect(true).to.equal(true) end) end) describe("Feature Y", function() SKIP() it("should be skipped", function() error("This will not run") end) end) describe("Feature Z", function() it("should also be skipped when Feature X is focused", function() error("This will not run either") end) itFOCUS("can focus individual tests", function() expect(1).to.equal(1) end) itSKIP("can skip individual tests", function() error("Skipped") end) end) end ``` ## Marking Broken Tests with FIXME **FIXME** marks tests as broken and skips them with an optional message. ```lua return function() describe("Broken feature", function() FIXME("Waiting for API changes") it("should work after refactor", function() error("This test is currently broken") end) end) describe("Another feature", function() itFIXME("needs database setup", function() expect(database.connect()).to.be.ok() end) it("should work normally", function() expect(true).to.equal(true) end) end) end ``` ## Extending Expectations with Custom Matchers **expect.extend** adds custom matchers for domain-specific assertions. ```lua return function() beforeAll(function() expect.extend({ toBeEven = function(value) return { pass = value % 2 == 0, message = string.format("Expected %d to be even", value) } end, toBeInRange = function(value, min, max) local inRange = value >= min and value <= max return { pass = inRange, message = string.format("Expected %d to be between %d and %d", value, min, max) } end }) end) describe("Custom matchers", function() it("should use custom even matcher", function() expect(4).toBeEven() expect(7).never.toBeEven() end) it("should use custom range matcher", function() expect(5).toBeInRange(1, 10) expect(15).never.toBeInRange(1, 10) end) end) end ``` ## Running Tests Programmatically with TestPlanner and TestRunner **TestPlanner.createPlan** and **TestRunner.runPlan** provide granular control over test execution. ```lua local TestEZ = require(game.ReplicatedStorage.TestEZ) -- Manual test workflow local modules = TestEZ.TestBootstrap:getModules(game.ReplicatedStorage.Tests) -- Create a test plan local plan = TestEZ.TestPlanner.createPlan( modules, "Calculator", -- Only run tests matching this pattern { debugMode = true } -- Extra environment variables ) -- Visualize the plan before running (optional) print(plan:visualize()) -- Execute the plan local results = TestEZ.TestRunner.runPlan(plan) -- Visualize results (optional) print(results:visualize()) -- Report results with custom reporter TestEZ.Reporters.TextReporter.report(results) -- Check specific result fields print(string.format("Tests run: %d", results.testsRan)) print(string.format("Failures: %d", results.failureCount)) print(string.format("Successes: %d", results.successCount)) ``` ## Using Alternative Reporters **TextReporterQuiet** and **TeamCityReporter** provide different output formats for various environments. ```lua local TestEZ = require(game.ReplicatedStorage.TestEZ) -- Use quiet reporter for minimal output local quietResults = TestEZ.TestBootstrap:run( { game.ReplicatedStorage.Tests }, TestEZ.Reporters.TextReporterQuiet ) -- Use TeamCity reporter for CI integration local ciResults = TestEZ.TestBootstrap:run( { game.ReplicatedStorage.Tests }, TestEZ.Reporters.TeamCityReporter, { showTimingInfo = true } ) -- Create custom reporter local CustomReporter = {} function CustomReporter.report(results) print("=== Custom Test Report ===") print(string.format("Total: %d | Passed: %d | Failed: %d", results.testsRan, results.successCount, results.failureCount )) if results.failureCount > 0 then for _, error in ipairs(results.errors) do print(string.format("[FAIL] %s: %s", error.test, error.message)) end end end local customResults = TestEZ.TestBootstrap:run( { game.ReplicatedStorage.Tests }, CustomReporter ) ``` TestEZ serves as the primary testing solution for Roblox Lua applications, offering a complete BDD testing experience with minimal configuration. Its main use cases include unit testing individual modules, integration testing game systems, regression testing during refactoring, and automated testing in CI/CD pipelines using Lemur. The framework's design supports both simple single-call test execution via TestBootstrap and advanced workflows with fine-grained control over test planning and execution. Integration patterns typically involve organizing tests in `.spec.lua` files alongside source code, using `beforeAll` and `init.spec.lua` for shared setup across test suites, and leveraging context for passing data between hooks and tests. The framework's support for FOCUS and SKIP enables rapid development by isolating specific tests during debugging, while custom matchers through `expect.extend` allow teams to build domain-specific assertion libraries. TestEZ integrates seamlessly with Rojo for syncing tests into Roblox places and works with standard Lua 5.1 interpreters paired with Lemur for CI testing outside the Roblox environment.