Skip to content
Donate

Unit Testing Guide (Rooibos Framework)

JellyRock uses the Mocha-inspired Rooibos framework for robust unit and integration testing of Roku/BrighterScript components.

What you’ll learn:

  • Writing unit tests with Rooibos framework
  • Using JellyRock’s BaseTestSuite and helper methods
  • Testing with mocks, stubs, and async patterns
  • Testing Scene Graph components
  • Best practices for Roku/BrightScript testing

See also: TDD Workflow Guide for focused development and rapid iteration


namespace tests
@suite("My First Test")
class MyFirstTest extends tests.BaseTestSuite
@it("validates a simple function")
function _()
result = isValid("hello")
m.assertTrue(result)
end function
end class
end namespace

Build and deploy tests using VSCode Run and Debug and select the desired build.

To manually build the unit tests:

Terminal window
npm run build:tests # Build all tests (unit + integration)
npm run build:tests-unit # Build unit tests only
npm run build:tests-integration # Build integration tests only
npm run build:tdd # Build in watch mode for TDD

💡 For rapid development workflow: See the TDD Workflow Guide for focused test execution and faster iteration.


Suite (@suite)
└── Describe Block (@describe)
└── Test Case (@it)
└── Parameterized Test (@params)

All tests in JellyRock:

  • MUST be written in BrighterScript (.bs files)
  • SHOULD follow naming: ComponentName.spec.bs
  • MUST be inside a namespace tests block
  • MUST extend tests.BaseTestSuite
AnnotationPurposeExample
@suite("name")Define test suite (required)@suite("User Tests")
@describe("name")Group related tests@describe("Authentication")
@it("description")Individual test case@it("validates input")
@params(a, b, c)Parameterized test data@params(1, 2, 3)
@onlyRun only this test/suite@only @it("debug this")
@ignoreSkip this test/suite@ignore @it("broken")
@SGNode("Type")Run test in component context@SGNode("ItemGrid")
namespace tests
@suite("isValid utility functions")
class IsValidTests extends tests.BaseTestSuite
protected override function setup()
super.setup() ' ALWAYS call parent setup!
m.testData = [1, 2, 3]
end function
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@describe("isValid()")
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@it("returns true for valid strings")
function _()
m.assertTrue(isValid("hello"))
end function
@it("returns false for invalid")
function _()
m.assertFalse(isValid(invalid))
end function
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@describe("Parameterized example")
'+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
@it("handles strings correctly")
@params("hello", true)
@params("", false)
@params(" ", false)
function _(input, expected)
m.assertEqual(isValidAndNotEmpty(input), expected)
end function
end class
end namespace

Style Notes:

  • Use +++++++++++++ around @describe blocks for readability
  • Function names can be anything (Rooibos renames them) - _() is common
  • Both function and sub work for test cases

All assertions are called on m: m.assertSomething(actual, expected).

AssertionPurposeExample
assertTrue(val)Assert truem.assertTrue(isValid(obj))
assertFalse(val)Assert falsem.assertFalse(isEmpty)
assertEqual(act, exp)Values equalm.assertEqual(result, 42)
assertNotEqual(act, exp)Values not equalm.assertNotEqual(userId, "")
assertInvalid(val)Value is invalidm.assertInvalid(errorObj)
assertNotInvalid(val)Value is not invalidm.assertNotInvalid(user)
assertArrayCount(arr, n)Array has N itemsm.assertArrayCount(items, 5)
assertArrayContains(arr, val)Array contains valuem.assertArrayContains(genres, "Action")
assertAAHasKey(aa, key)AA has keym.assertAAHasKey(user, "id")
assertAAContainsSubset(aa, sub)AA contains subsetm.assertAAContainsSubset(user, {id: "123"})
assertNodeCount(node, n)Node has N childrenm.assertNodeCount(parent, 5)
assertNodeContainsFields(node, fields)Node has fieldsm.assertNodeContainsFields(item, {id: "123"})

For complete assertion reference: Rooibos API Documentation


Test the same logic with different inputs to reduce code duplication.

@it("validates multiple input types")
@params(true, true)
@params(false, true)
@params(invalid, false)
@params("hello", true)
function _(input, expected)
result = isValid(input)
m.assertEqual(result, expected)
end function

Rules: Function MUST accept same number of parameters as @params entries. Up to 6 parameters per line, unlimited lines.

Control execution: Use @onlyParams(a, b) to run only specific params, or @ignoreParams(a, b) to skip them.


All test suites MUST extend tests.BaseTestSuite, which provides:

  • Automatic initialization of m.global with proper ContentNode structure
  • Mock data loading and transformation using production code paths
  • Helper methods for common testing patterns
MethodPurpose
loadTestUser(userName)Load mock user from JSON file in tests/source/mocks/users/
setTestDisplaySetting(libId, key, val)Set single display setting for testing
getTestServer()Get local server reference (minimizes rendezvous)
getTestUser()Get local user reference
getTestUserSettings()Get local settings reference
resetServer()Reset server to XML defaults
resetUser()Reset user to XML defaults

Mock data is stored in tests/source/mocks/:

  • servers/ - Server configurations (e.g., default.json)
  • users/ - User configurations (e.g., user-with-display-settings.json)
  • api/ - API responses

Mock User JSON Structure:

{
"id": "test-user-id",
"name": "Test User",
"settings": {
"display.library1.sortAscending": "true",
"display.library1.sortField": "DateCreated",
"ui.rowLayout": "fullwidth"
}
}

Key points: Display settings use dot notation "display.libraryId.settingKey". All values stored as strings (registry format). SessionDataTransformer converts types automatically.

@it("tests with proper mock data")
function _()
m.loadTestUser("user-with-display-settings")
result = someFunction()
m.assertEqual(result, expectedValue)
end function
' ❌ BAD - Bypasses ContentNode creation and transformers
m.global.user = { settings: {...} } ' This will fail!

Why this breaks: Bypasses ContentNode field definitions, production transformers, observer patterns, and causes type mismatches.


Isolate code under test by replacing dependencies with controlled implementations.

When to use: Testing API calls, Task nodes, external dependencies, complex objects.

Add to bsconfig.json:

{
"rooibos": {
"isGlobalMethodMockingEnabled": true,
"isGlobalMethodMockingEfficientMode": true
}
}
@it("verifies API call")
function _()
apiClient = { callApi: function(endpoint) return invalid }
m.mock(apiClient, "callApi")
m.expect(apiClient, "callApi", ["users"], {users: [{id: "1"}]})
result = apiClient.callApi("users")
m.assertEqual(result.users.Count(), 1)
m.assertMocks() ' Verify expectations met
end function
@it("stubs Task node")
function _()
task = CreateObject("roSGNode", "LoadItemsTask")
m.stub(task, "control") ' Prevent actual execution
' Simulate completion
task.output = {items: [{id: "1"}]}
m.assertEqual(task.output.items.Count(), 1)
end function
m.expectOnce(obj, "method", [args], returnValue) ' Called once
m.expectNone(obj, "method") ' Never called
m.expect(obj, "method", [args], returnValue, N) ' Called N times
m.assertMocks() ' Verify (MUST call at end)

Wait for asynchronous operations (Task nodes, field observers).

@it("waits for task completion")
function _()
task = CreateObject("roSGNode", "LoadItemsTask")
task.control = "RUN"
' Wait for field to change (500ms intervals, 10 retries = 5s timeout)
m.assertAsyncField(task, "state")
m.assertEqual(task.state, "DONE")
m.assertNotInvalid(task.output)
end function

Syntax: m.assertAsyncField(node, fieldName, timeout, retries)

Parameters: timeout (ms, default: 500), retries (default: 10)


Test Scene Graph components in their proper node context.

Requirements: "autoImportComponentScript": true in bsconfig.json

namespace tests
@suite("ItemGrid Component Tests")
@SGNode("ItemGrid") ' Creates test in ItemGrid context
class ItemGridTests extends tests.BaseTestSuite
@it("initializes with default values")
function _()
' m.node references the ItemGrid instance
m.assertNotInvalid(m.node)
m.assertEqual(m.node.subtype(), "ItemGrid")
m.assertEqual(m.node.numColumns, 6)
end function
end class
end namespace

Note: m.top and m.node refer to the same component instance.


Suite Setup (override setup())
└── BeforeEach (override beforeEach())
└── Test 1
└── AfterEach (override afterEach())
└── BeforeEach
└── Test 2
└── AfterEach
Suite TearDown (override teardown())
class MyTests extends tests.BaseTestSuite
protected override function setup()
super.setup() ' ⚠️ ALWAYS call in JellyRock!
m.sharedData = loadExpensiveData()
end function
protected override function teardown()
m.sharedData = invalid
end function
protected override function beforeEach()
m.testCounter = 0
end function
protected override function afterEach()
m.testCounter = invalid
end function
end class
@describe("Feature group")
@setup
function featureSetup()
m.featureData = loadFeatureData()
end function
@tearDown
function featureTearDown()
m.featureData = invalid
end function
@it("tests something")
function _()
' m.featureData is available
end function

For daily development, use TDD mode (see TDD Workflow Guide) to run only specific test files. This is cleaner and faster than annotation-based filtering.

When debugging a specific test within your TDD session:

@only ' Temporarily run only this test
@it("debug this test")
function _()
end function

⚠️ CRITICAL: Remove all @only annotations before committing!

Note: @only can be used on @suite, @describe, or @it to focus execution at any level.

❌ Don’t use @ignore to skip tests during development - use TDD file filtering instead.

✅ Only use @ignore for permanently disabled tests:

@ignore ' TODO: Fix in ticket #123 - API endpoint deprecated
@it("calls legacy endpoint")
function _()
end function

Best practice: Always include a comment explaining why the test is ignored and reference a ticket/issue number.

@noCatch ' Crash with stack trace on failure
@it("debug this")
function _()
end function

Or configure globally: "throwOnFailedAssertion": true, "failFast": true


1. Always Extend tests.BaseTestSuite and Call super.setup()

Section titled “1. Always Extend tests.BaseTestSuite and Call super.setup()”
' ✅ GOOD
namespace tests
@suite("My Tests")
class MyTests extends tests.BaseTestSuite
protected override function setup()
super.setup() ' Critical!
end function
end class
end namespace
' ❌ BAD
class MyTests extends rooibos.BaseTestSuite ' Wrong base class
' ✅ GOOD
@it("returns defaultValue when library doesn't exist in displaySettings")
' ❌ BAD
@it("test 1")
@it("retrieves stored setting")
@it("returns defaultValue when key doesn't exist")
@it("returns defaultValue when library doesn't exist")
@it("handles invalid input gracefully")

4. Use Parameterized Tests for Similar Cases

Section titled “4. Use Parameterized Tests for Similar Cases”
' ✅ GOOD - One test, many cases
@it("validates various inputs")
@params(true, "valid")
@params(false, "valid")
@params(invalid, "invalid")
function _(input, expected)
m.assertEqual(validateInput(input), expected)
end function
' ❌ BAD - Repeated tests
@it("validates true")
@it("validates false")
@it("validates invalid")
' ✅ GOOD - Single rendezvous
localUser = m.getTestUser()
userId = localUser.id
userName = localUser.name
' ❌ BAD - Multiple rendezvous (slow!)
userId = m.global.user.id ' Rendezvous 1
userName = m.global.user.name ' Rendezvous 2
' ✅ GOOD
m.loadTestUser("user-with-display-settings")
' ❌ BAD
m.global.user.settings = {...} ' Wrong type!

For rapid iteration during development, use the TDD Workflow with file-based filtering instead of @ignore annotations. This keeps your codebase clean and builds faster.


Test Crashes with “User not initialized”

Section titled “Test Crashes with “User not initialized””

Solution: Ensure test extends tests.BaseTestSuite and calls super.setup().

Causes:

  1. Comparing object references: Use m.assertEqual(obj1.id, obj2.id) not m.assertEqual(obj1, obj2)
  2. Type mismatch: m.assertEqual("true", true) passes due to coercion. Verify type first: m.assertTrue(Type(value) = "roBoolean")
  3. Async timing: Use m.assertAsyncField(task, "output") instead of immediately checking task.output

Solution: Verify code actually calls the mocked method before m.assertMocks().

Solutions:

  1. Disable code coverage: "isRecordingCodeCoverage": false
  2. Use @only to focus
  3. Check for unnecessary Task node usage
  4. Use "failFast": true

Solution: Use m.loadTestUser() instead of direct assignment. Direct assignment bypasses ContentNode creation.

Checklist:

  • File exists in tests/source/mocks/users/?
  • Filename correct (without .json)?
  • JSON valid?

Solution: Disable in .vscode/launch.json: "rendezvousTracking": false


namespace tests
@suite("My Feature Tests")
class MyFeatureTests extends tests.BaseTestSuite
protected override function setup()
super.setup()
end function
@describe("Feature area")
@it("does something")
function _()
m.assertTrue(true)
end function
@it("handles edge case")
@params(1, "expected1")
@params(2, "expected2")
function _(input, expected)
result = myFunction(input)
m.assertEqual(result, expected)
end function
end class
end namespace
m.assertTrue(val)
m.assertFalse(val)
m.assertEqual(actual, expected)
m.assertInvalid(val)
m.assertNotInvalid(val)
m.assertArrayCount(arr, n)
m.assertArrayContains(arr, val)
m.assertAAHasKey(aa, "key")
m.assertNodeCount(node, n)
m.assertAsyncField(node, "field")
m.assertMocks()
m.loadTestUser("filename")
m.setTestDisplaySetting("libId", "key", value)
server = m.getTestServer()
user = m.getTestUser()
settings = m.getTestUserSettings()
Terminal window
npm run build:tests # Build all tests
npm run build:tests-unit # Build unit tests only
npm run build:tests-integration # Build integration tests only
npm run build:tdd # Build with TDD config (see TDD guide)

💡 TDD Workflow: For focused test execution and rapid iteration, see the TDD Workflow Guide.