docs/how-to/test-driven-development.md
This guide shows a practical, repeatable test-driven development loop for building an F´ component. You’ll model behavior first in FPP, stub the implementation, write tests that fail, then implement the component until the tests pass—iterating as needed.
Test-Driven Development (TDD) is a software development practice where tests are written before the flight code. Rather than writing code and then verifying it later, TDD flips the workflow:
This approach aligns extremely well with the model-driven development of F Prime because testing only relies on the model of the component not the implementation. Thus, it is easy to develop tests that test to the desired behavior using the component's model as an interface and then implement that model to pass the tests.
Before starting, you should have:
fprime-util).
Completed the LED Blinker tutorial for F´ (so you’ve seen F´ unit-testing).[!TIP] Make sure your unit-test development environment is set-up via
fprime-util generate.
To keep things concrete, we’ll walk through a tiny example: a Counter component that:
increment.Count with the current count.You can swap this for your real component; the test-driven development process stays the same.
[!TIP] You can create a new component with
fprime-util new --component
In short, the test-driven development process in F´ is: design, test, and implement to the test. This places testing before component implementation as opposed to the traditional process: design, implement, and test the implementation.
Start by expressing the component's interface as an FPP model. Our example component looks like the following:
module Demo {
@ A simple counting component. Increments on invocation of `increment`
passive component Counter {
import Fw.Channel
@ Count of the incoming port calls
telemetry Count: FwSizeType
@ A no-argument port triggering implementation
guarded input port increment: Fw.Signal
time get port timeCaller
}
}
Why design in FPP first? In F´ the FPP model is the source of truth that drives code generation. Modeling first ensures you can take advantage of the generated test harness and autocoded functions when writing your tests.
Ensure your component exists under a module (e.g., Demo/Counter/) and is included in your CMakeLists.txt. Then generate the implementation files for your component using: fprime-util impl. If this is your first iteration of the test-driven process, then copy or rename the template into their correct place (i.e. Counter.cpp and Counter.hpp).
If you are iterating on the design, copy over the new blank handlers, and leave them blank.
Why?
Generating (blank) implementation classes will allow the auto code to compile, but implementation has not begun. This will allow us to compile and run tests without compilation errors.
Now we will implement tests that ensure our implementation is working correctly before we've written the implementation!
Next generate the implementation files for the unit-tests. If this is a second iteration, you'll need to copy over updated code.
fprime-util generate --ut
fprime-util impl --ut
[!TIP] Remember to add or uncomment a call to
register_fprime_utin theCMakeLists.txtof your component after the initial implementation files have been generated!
Move the files into place and implement test(s) as you see fit. Below is a test that will ensure our counter component responds correctly to calls to the increment port.
void CounterTester::test_increment() {
// Loop a random number of times, ensuring that telemetry matches the current count
for (U32 i = 0; i < STest::Pick::lowerUpper(1, MAX_HISTORY_SIZE)) {
this->invoke_to_increment(0);
ASSERT_TLM_Count(i, i + 1);
}
}
[!TIP]
ASSERT_TLM_Countis used to assert on the telemetry history.ASSERT_TLM_Count(i, i +1)asserts that the telemetry channel at index i is equal toi + 1.this->invoke_to_increment(0), which invokes the increment port (with port index 0), was called first so this assertion should hold. Finally,STest::Pick::lowerUpper(1, MAX_HISTORY_SIZE)picks a random number of test iterations to run through. In this case we bound this number byMAX_HISTORY_SIZEto avoid overflowing the history size.
[!CAUTION] Remember to add
test_incrementto theCounterTester.hppand invoke it with a test inCounterTestMain.cpp. This can be done by replacing thetoDotest from the implementation template.
Add your test(s) and ensure they fail when running fprime-util check.
Why do the tests fail?
The art of test-driven development is to focus on writing correct tests that ensure correct behavior, then implementing the code to pass the tests. Since implementation has not happened, our test will fail.
Now that we have a test to check the behavior of this component, we can implement the component iterating until the tests pass! The result: a good component and matching tests.
The below handler should cause the test to pass.
void Counter::increment_handler(FwIndexType portNum) {
this->m_count++;
this->tlmWrite_Count(m_count);
}
[!TIP] Remember to define
m_countand initialize it in the.hpp. The Hello World Tutorial implements a counter.
Re-run the tests:
fprime-util check
They should now pass. If not, adjust the implementation or the tests until the intended behavior is captured and verified.
You can now repeat the process by tuning the design (FPP), adding more tests, and implementing the component to pass all the tests!
For reference, here is the layout of our demo component.
Demo/
Counter/
CMakeLists.txt
Counter.fpp
Counter.hpp
Counter.cpp
test/
ut/
CounterTester.hpp
CounterTester.cpp
CounterTestMain.cpp