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
FeatureExchangeBothSupportSCFeaturesHaveSC
test 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:
Phase1
does not notifyFakeListener
of 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()));