This guide explains how to write tests for Fuchsia's
bt-host driver by detailing the
SMP_Phase1Test
test fixture and the
FeatureExchangeBothSupportSCFeaturesHaveSC
test case.
The bt-host driver implements most of the Bluetooth Core Specification v5.2,
Volume 3 (Host Subsystem).
The Fuchsia project places a strong emphasis on automated testing and the Bluetooth team strives to be leaders of testing culture.
In order to merge your bt-host driver change in the Fuchsia source tree, you
need to write automated tests for that change.
Introduction
bt-host is a relatively large codebase with many layers of abstraction.
Below is a diagram of the bt-host driver's main logical components:
{#architecture-diagram}
Every abstraction layer in the graph roughly corresponds to an entire protocol.
When working with bt-host tests, focus on understanding the relationship
between the layer you're currently working on (e.g. SM), and the layer(s)
directly beneath it (e.g. L2CAP).
While each layer may have additional internal layers of abstraction, these inter-protocol relationships are most frequently mocked and/or exercised in tests.
Resources
bt-host is written in C++, and Fuchsia uses the
gUnit
Googletest library for C++ tests.
To work with bt-host unit tests, you need a solid understanding of the
following resources:
The following topics occasionally come up while writing bt-host tests and can
be referenced as needed:
bt-host unit test overview
Most bt-host tests are written in the following pattern:
Create a test fixture to store test doubles and the Layer Under Test (LUT).
Construct the LUT within that test fixture, using test doubles in place of the LUT's depenencies.
Exercise a functionality within the LUT. For example, this guide examines the
FeatureExchangeBothSupportSCFeaturesHaveSCtest case.Validate that the higher-level command results in an expected behavior.
Test fixtures
GTest test fixtures are reusable environments that often store test
dependencies and provide convenience methods for writing unit tests. The test
fixture used in this example is the SMP_Phase1Test class.
Test doubles
Test doubles are objects used to substitute for real objects in test code. There
are many different types of test doubles. The two example test doubles used in
SMP_Phase1Test are FakeChannel and
FakeListener.
SMP_Phase1Test test fixture
SMP_Phase1Test
is the test fixture used to test the
sm::Phase1
class.
The Phase1 class is responsible for Phase 1 of Bluetooth Low Energy (BLE)
pairing, in which the devices negotiate the security features of the pairing.
This section annotates the test fixture setup code of SMP_Phase1Test as a
representative example of
"Creating a test fixture" and "Constructing the LUT".
It may be helpful to have
phase_1_unittest.cc
open while reading this section.
SetUp(), TearDown(), and NewPhase1() methods
The constructor of SMP_Phase1Test does nothing. Instead, bt-host test
fixtures typically use the GTest SetUp() method to initialize the test
fixture.
void SetUp() override { NewPhase1(); }
NewPhase1 is a protected visibility method with defaultable parameters.
bt-host test fixtures commonly delegate to a New<test-fixture-name> method
with defaultable parameters from SetUp(). The New* method does the work of
setting up resources/test doubles and creating the LUT. This enables test
cases1 to reinitialize the test fixture by calling New* with different
parameters.
void NewPhase1(Role role = Role::kInitiator,
Phase1Args phase_args = Phase1Args(),
hci::Connection::LinkType ll_type =
hci::Connection::LinkType::kLE) {
For NewPhase1, the configurable aspects are:
- The device role
- The transport type
- A struct that holds the rest of the Phase1 arguments, which makes it easier to change only one of the "default" arguments:
Phase1Args struct
struct Phase1Args {
PairingRequestParams preq = PairingRequestParams();
IOCapability io_capability = IOCapability::kNoInputNoOutput;
BondableMode bondable_mode = BondableMode::Bondable;
SecurityLevel level = SecurityLevel::kEncrypted;
bool sc_supported = false;
};
L2CAP mock dependency
L2CAP channels provide a logical connection to a peer protocol/service, and
are depended on by higher-level protocols like ATT, GATT, SMP, SDP.
FakeChannel is used as a mock dependency to test how real objects send and
receive messages over
L2CAP channels.
The first test double created in NewPhase1 is a FakeChannel mock object:
uint16_t mtu =
phase_args.sc_supported ? l2cap::kMaxMTU : kNoSecureConnectionsMtu;
ChannelOptions options(cid, mtu);
options.link_type = ll_type;
...
fake_chan_ = CreateFakeChannel(options);
sm_chan_ = std::make_unique<PairingChannel>(fake_chan_);
The CreateFakeChannel method is available because SMP_Phase1Test inherits
from
FakeChannelTest.2
FakeListener
In real code, PairingPhase uses PairingPhase::Listener to communicate with
the higher-level SecurityManager class. FakeListener provides a mock of this
dependency for testing.
listener_ = std::make_unique<FakeListener>();
While not a protocol-level dependency, FakeListener exemplifies another common
bt-host test pattern. Classes often take interface pointers to communicate
with layers above them. Test doubles implementing these interfaces are passed to
the LUT to verify that the LUT communicates correctly with the layer above it.
Completion callback
Phase1 stores a callback parameter. When Phase1 completes, it returns the
results of Phase1 through this callback. complete_cb is used as this
callback when instantiating Phase1.
complete_cb stores the results of Phase1 (in this case the features,
preq, and pres arguments) into test fixture variables (features_,
last_pairing_req_, and last_pairing_res_) so that test cases can check that
these variables are generated correctly.
auto complete_cb = [this](PairingFeatures features,
PairingRequestParams preq,
PairingResponseParams pres) {
feature_exchange_count_++;
features_ = features;
last_pairing_req_ = util::NewPdu(sizeof(PairingRequestParams));
last_pairing_res_ = util::NewPdu(sizeof(PairingResponseParams));
PacketWriter preq_writer(kPairingRequest, last_pairing_req_.get());
PacketWriter pres_writer(kPairingResponse, last_pairing_res_.get());
*preq_writer.mutable_payload<PairingRequestParams>() = preq;
*pres_writer.mutable_payload<PairingResponseParams>() = pres;
};
LUT Instantiation
The next step is to create the Phase1 LUT according to the NewPhase1
parameters. The LUT is stored in the test fixture's phase_1_ variable.
if (role == Role::kInitiator) {
phase_1_ = Phase1::CreatePhase1Initiator(
sm_chan_->GetWeakPtr(), listener_->as_weak_ptr(),
phase_args.io_capability, phase_args.bondable_mode,
phase_args.level, std::move(complete_cb));
} else {
phase_1_ = Phase1::CreatePhase1Responder(
sm_chan_->GetWeakPtr(), listener_->as_weak_ptr(),
phase_args.preq, phase_args.io_capability,
phase_args.bondable_mode, phase_args.level,
std::move(complete_cb));
}
Remaining methods
The rest of Phase1 methods are trivial get methods to do the following:
- Expose test doubles for manipulation.
- Check
Phase1's result output against test expectations.
FeatureExchangeBothSupportSCFeaturesHaveSC Test case
This test case verifies that if both devices involved in pairing support a
feature, in this case the Secure Connections (SC) feature, the PairingFeatures
returned by Phase1's complete callback correctly reports this.
The default NewPhase1 parameters
don't support Secure Connections, so the code sets just
the SC field of Phase1Args and leaves the rest defaulted for NewPhase1:
Phase1Args args;
args.sc_supported = true;
NewPhase1(Role::kInitiator, args);
The L2CAP messages used in this test case are written out, with the feature bit
under test (kSC) set:
const auto kRequest = StaticByteBuffer(
// [...omitted]
AuthReq::kSC | AuthReq::kBondingFlag,
// [...omitted]
);
const auto kResponse = StaticByteBuffer(
// [...omitted]
AuthReq::kSC | AuthReq::kBondingFlag,
// [...omitted]
);
Parts of bt-host run on asynchronous task dispatchers. In this case,
FakeChannelTest runs its FakeChannel on a dispatcher. Phase1::Start, which
performs the work of Phase1, also needs needs run on this dispatcher.
PostTask puts the Start method onto the dispatcher.
FakeChannelTest::Expect then runs the dispatcher and check that the next
message Phase1 sends to L2CAP is kRequest:
// Initiate the request in a loop task for Expect to detect it.
async::PostTask(dispatcher(), [this] { phase_1()->Start(); });
ASSERT_TRUE(Expect(kRequest));
The fake_chan is used to emulate receiving a response from the peer, which
completes the Phase 1 feature exchange. In this case, the code explicitly runs
the task dispatcher loop by calling RunLoopUntilIdle(), whereas
FakeChannelTest::Expect did that internally:
fake_chan()->Receive(kResponse);
RunLoopUntilIdle();
Finally, the code verifies that:
Phase1does not notifyFakeListenerof an error- All expected parameters are passed up in
Phase1's complete callback.
EXPECT_EQ(0, listener()->pairing_error_count());
EXPECT_EQ(1, feature_exchange_count());
EXPECT_TRUE(features().initiator);
EXPECT_TRUE(features().secure_connections);
ASSERT_TRUE(last_preq());
ASSERT_TRUE(last_pres());
EXPECT_TRUE(ContainersEqual(kRequest, *last_preq()));
EXPECT_TRUE(ContainersEqual(kResponse, *last_pres()));