Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 14

Verifying FSM State Transitions

Recommended resources

Hello! Let's get started with the final lesson in our module on building a reusable FSM.

In our previous lessons, we've constructed a complete, reusable FSM ModuleScript from the ground up. We created a constructor, methods to define states with callbacks (defineState), a way to set the rules of movement (defineTransitions), and finally, the engine itself—a transitionTo method that handles transitions and respects guard conditions.

Our FSM module is feature-complete, but is it correct? Before we trust it to run our game's AI, UI, or round logic, we need to prove it works as expected.

Today's goal is to write a test script that instantiates the FSM module and verifies correct state transitions. This process, often called unit testing, is crucial for building robust and reliable code. We will create a separate script dedicated to putting our FSM through its paces, checking that it handles valid, invalid, and guarded transitions correctly.

1. The Philosophy of Testing

In programming, testing isn't just about running your code to see if it crashes. It's a systematic process of proving that your code behaves exactly as you intended under various conditions. A good test script doesn't just check the "happy path" where everything goes right; it also deliberately tries to break things to ensure your code handles errors gracefully.

We'll follow a simple but powerful pattern for our tests known as Arrange, Act, Assert:

  1. Arrange: Set up the initial conditions for your test. This involves creating an instance of your FSM, defining its states, and setting up its transition rules.
  2. Act: Perform the specific action you want to test. In our case, this will almost always be a call to FSM:transitionTo().
  3. Assert: Check if the outcome of the action matches your expectation. Did the state change correctly? Did it correctly refuse to change? We'll use print() statements and check the output console to verify our results.

2. Setting Up the Test Script

Let's start by creating the environment for our test.

  1. In the Roblox Explorer, navigate to ServerScriptService.
  2. Create a new Script and name it FSM_Test.
  3. Ensure this script is a sibling of your FSM ModuleScript, or adjust the path accordingly.

Inside FSM_Test, the first thing we need to do is get access to our module using require().

-- In FSM_Test Script

-- Arrange: Get the module we want to test
local FSM = require(script.Parent.FSM)

print("FSM Module loaded. Beginning tests...")

This simple setup is the foundation for any script that uses your module.


3. Writing Test Cases

We will use a simple "light switch" as the subject for our test. It's a classic example because its states (On, Off) and transitions (FlickOn, FlickOff) are universally understood.

Test Case 1: A Valid Transition

Let's test the most basic functionality: a simple, permitted transition from Off to On.

Arrange: We create a new FSM, define On and Off states with enter callbacks that print to the console, and define a transition from Off to On.

Act: We call transitionTo("On").

Assert: We expect to see the "Entering On" message and confirm the final state is On.

-- In FSM_Test Script, after requiring the module

-- ARRANGE
local lightSwitch = FSM.new("Off") -- Start in the "Off" state

lightSwitch:defineState("Off", {
    enter = function(prevState) print(" -> Light is now Off. Came from: " .. tostring(prevState)) end,
    exit = function(nextState) print(" <- Exiting Off. Going to: " .. nextState) end
})

lightSwitch:defineState("On", {
    enter = function(prevState) print(" -> Light is now On. Came from: " .. prevState) end,
    exit = function(nextState) print(" <- Exiting On. Going to: " .. nextState) end
})

lightSwitch:defineTransitions({
    { from = "Off", to = "On" },
    { from = "On", to = "Off" }
})

print("--- TEST CASE 1: Valid Transition ---")
print("Initial state:", lightSwitch.currentStateName)

-- ACT
lightSwitch:transitionTo("On")

-- ASSERT
print("Final state:", lightSwitch.currentStateName)
print("-------------------------------------\n")

-- Expected Output for Test Case 1:
-- Initial state: Off
-- <- Exiting Off. Going to: On
-- -> Light is now On. Came from: Off
-- Final state: On

Test Case 2: An Illegal Transition

What happens if we try to transition to a state that isn't allowed by our rules? Let's add a Broken state but provide no rule to transition to it.

Arrange: The FSM is currently in the On state from the previous test.

Act: We attempt to call transitionTo("Broken").

Assert: We expect to see the warning message from our transitionTo function, and the state should remain On.

-- Add this after the code from Test Case 1

lightSwitch:defineState("Broken", {
    enter = function() print("This should never be printed!") end
})

print("--- TEST CASE 2: Illegal Transition ---")
print("Initial state:", lightSwitch.currentStateName)

-- ACT
lightSwitch:transitionTo("Broken") -- This transition is not defined

-- ASSERT
print("Final state:", lightSwitch.currentStateName)
print("---------------------------------------\n")

-- Expected Output for Test Case 2:
-- Initial state: On
-- FSM Error: Transition from 'On' to 'Broken' is not permitted. (A warning in the console)
-- Final state: On

Test Case 3: A Guarded Transition

This is the most important test. We need to verify that our guard conditions work, both when they pass and when they fail. Let's say we can only transition to Off if a condition isPowerOn is true.

Arrange: We'll need a new FSM instance or redefine the transitions for our existing one to include a guard. Let's redefine for simplicity.

Act & Assert (Failure): We'll set isPowerOn to false and try to transition to Off. We expect the transition to be silently blocked.

Act & Assert (Success): We'll then set isPowerOn to true and try again. This time, we expect the transition to succeed.

-- Add this after the code from Test Case 2

-- Re-arranging transitions to include a guard
local isPowerOn = false
lightSwitch:defineTransitions({
    { from = "Off", to = "On" },
    { from = "On", to = "Off", guard = function()
        print("Guard check: isPowerOn is", isPowerOn)
        return isPowerOn
    end}
})

print("--- TEST CASE 3: Guarded Transition ---")
print("Initial state:", lightSwitch.currentStateName)

print("\nPart A: Guard fails")
isPowerOn = false
lightSwitch:transitionTo("Off")
print("State after failed guard:", lightSwitch.currentStateName)

print("\nPart B: Guard succeeds")
isPowerOn = true
lightSwitch:transitionTo("Off")
print("State after successful guard:", lightSwitch.currentStateName)
print("---------------------------------------\n")

-- Expected Output for Test Case 3:
-- Initial state: On

-- Part A: Guard fails
-- Guard check: isPowerOn is false
-- State after failed guard: On

-- Part B: Guard succeeds
-- Guard check: isPowerOn is true
-- <- Exiting On. Going to: Off
-- -> Light is now Off. Came from: On
-- State after successful guard: Off

This small script has now successfully verified the core logic of our FSM module! It confirms that legal transitions work, illegal ones are blocked, and guards are respected.


4. Viewing Real-World Test Examples

The simple script we wrote is a form of a "unit test." Developers use this pattern constantly. The following resources show how others in the Roblox community apply these same principles to test their FSMs.

First, the GitHub repository for a popular FSM library contains an "Example usage" section that is, in fact, a perfect self-contained test script.

BusyCityGuy/finite-state-machine-luau: An intuitive fully ... - GitHub

This example from the StateQ FSM library on GitHub is a fantastic model for a test script. Notice how it defines states, sets up events (their version of transitions), and then calls the handler to test both valid and invalid scenarios, even noting the expected warning for the illegal call.

In the resource, find the heading 'Example usage'. Read through the Luau code block provided. Compare its structure (setup, transition calls, comments about expected results) to the test script we just wrote.

This next video shows a more "in-game" style of testing, using game elements to trigger state changes and the output log to verify them.

You NEED a State Manager in Your Roblox Game (Here’s Why)

In this video by ArTheDev, while the FSM is slightly different, the testing methodology is very relevant. The developer uses in-game actions (like a proximity prompt) to 'act' and then uses print statements to 'assert' the result.

Watch the clip from 15:36 to 20:37. Pay attention to how the developer: Uses a ProximityPrompt to trigger a state change (the 'Act' step). Immediately uses a returnState function and print() to check if the state was set correctly (the 'Assert' step). Sets up two separate parts to distinguish between setting a state and checking it, proving the change was successful.


Conclusion

You have successfully built and, more importantly, verified a reusable FSM module. This is a significant milestone. By writing a dedicated test script, you've gained confidence that your tool is not only powerful but also predictable and reliable.

Key Takeaways:

  • Testing is a systematic process of verifying your code's behavior.
  • The Arrange, Act, Assert pattern provides a clear structure for writing test cases.
  • A good test script checks not only for successful operations but also for correct handling of errors, invalid inputs, and boundary conditions (like guards).
  • Verifying transitions can be as simple as printing the current state to the console and checking that it matches your expectation.

Our FSM module is now "production-ready." We have a robust, tested tool in our developer toolkit. We are now fully prepared to apply it to the complex, real-world problems you wanted to solve.

In our next lesson, we will begin Module 4 and dive into our first major application: using our new FSM to design and implement the AI for a non-player character.