Understanding bt-host unit tests

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:

Diagram of bt-host 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:

  1. Create a test fixture to store test doubles and the Layer Under Test (LUT).

  2. Construct the LUT within that test fixture, using test doubles in place of the LUT's depenencies.

  3. Exercise a functionality within the LUT. For example, this guide examines the FeatureExchangeBothSupportSCFeaturesHaveSC test case.

  4. 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:

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 notify FakeListener 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()));

Notes


  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.