bt-host
unit testsThis 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.
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:
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.
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 overviewMost 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.
Note: Fuchsia does not allow usage of the gMock framework, so any mock dependencies must be written without it. For more information, see [Library restrictions](/docs/development/languages/c-cpp/library_restrictions.md).
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.
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 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_phase1_test}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()
methodsThe constructor of SMP_Phase1Test
does nothing. Instead, bt-host
test fixtures typically use the GTest SetUp()
method to initialize the test fixture.
Note: The GTest FAQs have some subtle guidance about when to use the constructor vs. SetUp
, but it is a bt-host
best practice to use SetUp()
.
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 cases[^1] 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:
Phase1Args
structstruct Phase1Args { PairingRequestParams preq = PairingRequestParams(); IOCapability io_capability = IOCapability::kNoInputNoOutput; BondableMode bondable_mode = BondableMode::Bondable; SecurityLevel level = SecurityLevel::kEncrypted; bool sc_supported = false; };
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]
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.
Note: For another example of this pattern, see BrEdrConnectionManager
's PairingDelegate
pointer and how it's mocked out in tests.
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; };
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)); }
The rest of Phase1
methods are trivial get methods to do the following:
Phase1
's result output against test expectations.FeatureExchangeBothSupportSCFeaturesHaveSC
Test caseThis 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 notify FakeListener
of an errorPhase1
'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()));
[^1]: A test case is actually a subclass of the test fixture
[^2]: PairingChannel is an SM-specific wrapper that is not relevant to the functioning of these tests.