docs: add mojo testing sketch
Change-Id: Ie449ab12891f60d70db097cce4542c0201132dae Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2891199 Reviewed-by: Ken Rockot <rockot@google.com> Commit-Queue: Elly Fong-Jones <ellyjones@chromium.org> Cr-Commit-Position: refs/heads/master@{#882696}
This commit is contained in:

committed by
Chromium LUCI CQ

parent
8c1742fe6d
commit
99516e2e69
206
docs/mojo_testing.md
Normal file
206
docs/mojo_testing.md
Normal file
@ -0,0 +1,206 @@
|
||||
# Testing With Mojo
|
||||
|
||||
This document outlines some best practices and techniques for testing code which
|
||||
internally uses a Mojo service. It assumes familiarity with the
|
||||
[Mojo and Services] document.
|
||||
|
||||
## Example Code & Context
|
||||
|
||||
Suppose we have this Mojo interface:
|
||||
|
||||
```mojom
|
||||
module example.mojom;
|
||||
|
||||
interface IncrementerService {
|
||||
Increment(int32 value) => (int32 new_value);
|
||||
}
|
||||
```
|
||||
|
||||
and this C++ class that uses it:
|
||||
|
||||
```c++
|
||||
class Incrementer {
|
||||
public:
|
||||
Incrementer();
|
||||
|
||||
void SetServiceForTest(
|
||||
mojo::PendingRemote<mojom::IncrementerService> service);
|
||||
|
||||
// The underlying service is async, so this method is too.
|
||||
void Increment(int32_t value,
|
||||
IncrementCallback callback);
|
||||
|
||||
private;
|
||||
mojo::Remote<mojom::IncrementerService> service_;
|
||||
};
|
||||
|
||||
void Incrementer::SetServiceForTesting(
|
||||
mojo::PendingRemote<mojom::IncrementerService> service) {
|
||||
service_.Bind(std::move(service));
|
||||
}
|
||||
|
||||
void Incrementer::Increment(int32_t value, IncrementCallback callback) {
|
||||
if (!service_)
|
||||
service_ = LaunchIncrementerService();
|
||||
service_->Increment(value, std::move(callback));
|
||||
}
|
||||
```
|
||||
|
||||
and we wish to swap a test fake in for the underlying IncrementerService, so we
|
||||
can unit-test Incrementer. Specifically, we're trying to write this (silly) test:
|
||||
|
||||
```c++
|
||||
// Test that Incrementer correctly handles when the IncrementerService fails to
|
||||
// increment the value.
|
||||
TEST(IncrementerTest, DetectsFailureToIncrement) {
|
||||
Incrementer incr;
|
||||
FakeIncrementerService service;
|
||||
incr.SetServiceForTest(service);
|
||||
|
||||
// Incrementing is async, so we have to wait...
|
||||
base::RunLoop loop;
|
||||
int returned_value;
|
||||
incr.Increment(0,
|
||||
base::BindLambdaForTesting([&](int value) {
|
||||
returned_value = value;
|
||||
loop.Quit();
|
||||
}));
|
||||
loop.Run();
|
||||
|
||||
EXPECT_EQ(0, returned_value);
|
||||
}
|
||||
```
|
||||
|
||||
## The Fake Service Itself
|
||||
|
||||
This part is fairly straightforward. Mojo generated a class called
|
||||
mojom::IncrementerService, which is normally subclassed by
|
||||
IncrementerServiceImpl (or whatever) in production; we can subclass it
|
||||
ourselves:
|
||||
|
||||
```c++
|
||||
class FakeIncrementerService : public mojom::IncrementerService {
|
||||
public:
|
||||
void Increment(int32_t value, IncrementCallback callback) override {
|
||||
// Does not actually increment, for test purposes!
|
||||
std::move(callback).Run(value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Services
|
||||
|
||||
If we plug the FakeIncrementerService in in our test:
|
||||
|
||||
```c++
|
||||
mojo::Receiver<IncrementerService> receiver{&fake_service};
|
||||
incrementer->SetServiceForTest(receiver);
|
||||
```
|
||||
|
||||
we can invoke it and wait for the response as we usually would:
|
||||
|
||||
```c++
|
||||
base::RunLoop loop;
|
||||
incrementer->Increment(1, base::BindLambdaForTesting(...));
|
||||
loop.Run();
|
||||
```
|
||||
|
||||
... and all is well. However, we might reasonably want a more flexible
|
||||
FakeIncrementerService, which allows for plugging different responses in as the
|
||||
test progresses. In that case, we will actually need to wait twice: once for the
|
||||
request to arrive at the FakeIncrementerService, and once for the response to be
|
||||
delivered back to the Incrementer.
|
||||
|
||||
## Waiting For Requests
|
||||
|
||||
To do that, we can instead structure our fake service like this:
|
||||
|
||||
```c++
|
||||
class FakeIncrementerService : public mojom::IncrementerService {
|
||||
public:
|
||||
void Increment(int32_t value, IncrementCallback callback) override {
|
||||
CHECK(!HasPendingRequest());
|
||||
last_value_ = value;
|
||||
last_callback_ = std::move(callback);
|
||||
if (wait_loop_)
|
||||
wait_loop_->Quit();
|
||||
}
|
||||
|
||||
bool HasPendingRequest() const {
|
||||
return bool(last_callback_);
|
||||
}
|
||||
|
||||
void WaitForRequest() {
|
||||
if (HasPendingRequest())
|
||||
return;
|
||||
wait_loop_ = std::make_unique<base::RunLoop>();
|
||||
wait_loop_->Run();
|
||||
}
|
||||
|
||||
void AnswerRequest(int32_t value) {
|
||||
CHECK(HasPendingRequest());
|
||||
std::move(last_callback_).Run(value);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
That having been done, our test can now observe the state of the code under test
|
||||
(in this case the Incrementer service) while the mojo request is pending, like
|
||||
so:
|
||||
|
||||
```c++
|
||||
FakeIncrementerService service;
|
||||
mojo::Receiver<mojom::IncrementerService> receiver{&service};
|
||||
|
||||
Incrementer incrementer;
|
||||
incrementer->SetServiceForTest(receiver);
|
||||
incrementer->Increment(1, base::BindLambdaForTesting(...));
|
||||
|
||||
// This will do the right thing even if the Increment method later becomes
|
||||
// synchronous, and exercises the same async code paths as the production code
|
||||
// will.
|
||||
service.WaitForRequest();
|
||||
service.AnswerRequest(service.last_value() + 2);
|
||||
|
||||
// The lambda passed in above will now asynchronously run somewhere here,
|
||||
// since the response is also delivered asynchronously by mojo.
|
||||
```
|
||||
|
||||
## Test Ergonomics
|
||||
|
||||
The async-ness at both ends can create a good amount of boilerplate in test
|
||||
code, which is unpleasant. This section gives some techniques for reducing it.
|
||||
|
||||
### Sync Wrappers
|
||||
|
||||
One can use the [synchronous runloop] pattern to make the mojo calls appear to
|
||||
be synchronous *to the test bodies* while leaving them asynchronous in the
|
||||
production code. Mojo actually generates test helpers for this already! We can
|
||||
include `incrementer_service.mojom-test-utils.h` and then do:
|
||||
|
||||
```c++
|
||||
int32_t Increment(Incrementer* incrementer, int32_t value) {
|
||||
int32_t result;
|
||||
mojom::IncrementerAsyncWaiter sync_incrementer(incrementer);
|
||||
sync_incrementer.Increment(value, &result);
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
Note that this only works if FakeIncrementerService does not need to be told
|
||||
when to send a response (via AnswerRequest or similar) - if it does, this
|
||||
pattern will deadlock!
|
||||
|
||||
To avoid that, the cleanest approach is to have the FakeIncrementerService
|
||||
either contain a field with the next expected value, or a callback that produces
|
||||
expected values on demand, so that your test code reads like:
|
||||
|
||||
```c++
|
||||
service.SetNextValue(2);
|
||||
EXPECT_EQ(Increment(incrementer, 1), 2);
|
||||
```
|
||||
|
||||
or similar.
|
||||
|
||||
[Mojo and Services]: mojo_and_services.md
|
||||
[synchronous runloop]: patterns/synchronous-runloop.md
|
Reference in New Issue
Block a user