Unlike static utilities tests, in interactor tests we will always need to instantiate the interactor class that we want to test.
test('4 is not a prime number', () {
final sut = PrimeNumberInteractorImpl();
expect(sut.isPrime(4), false);
});
Here we are using naming pattern where we name interactor that we want to test sut
which stands for System Under Test (or Subject Under Test). This naming is not required, but when having more complex tests with multiple mocked classes working together, it helps a lot to easily identify what exactly is being tested here.
This previous interactor example had no external dependencies (which is rare). Now we will introduce Fakes.
Fake
Fake is a common term for swapped class implementation. In the following example we are testing UserInteractorImpl(storageRepository)
that accepts StorageRepository as dependency. StorageRepository interface looks like:
abstract class StorageRepository {
Future<bool> setString(String key, String value);
Future<bool> setInt(String key, int value);
Future<bool> setDouble(String key, double value);
Future<bool> setBool(String key, bool value);
Future<String?> getString(String key);
Future<double?> getDouble(String key);
Future<int?> getInt(String key);
Future<bool?> getBool(String key);
Future<bool?> remove(String key);
}
The real implementation is using SharedPreferences, but we can’t use them in unit testing. For the shared preferences storage, it’s best to use fake implementation that uses Map
as a storage. Then all the behaviour will be same as in real SharedPreferences, except for the persistence across tests - which we don’t want anyway.
To create this Fake you could implement StorageRepository
by yourself. But another option is using Fake
from mockito package. It allows you to only implement a few selected methods, you don’t have to provide implementation for all of them:
class FakeStorageRepository extends Fake implements StorageRepositoryImpl {
final storage = <String, dynamic>{};
@override
Future<bool> setString(String key, String value) async {
storage[key] = value;
return true;
}
@override
Future<String?> getString(String key) async => storage[key];
}
This Fake can be then used in place of every class that needs StorageRepository in tests. In the end our test will look like:
test('Test value is written in interactor', () async {
const userName = 'John';
final sut = UserInteractorImpl(FakeStorageRepository());
sut.saveUserName(userName);
expect(await sut.getUserName(), userName);
});
Mocks
In the following example, interactor that we are testing has a repository dependency, which is often the case:
class WeatherInteractorImpl extends WeatherInteractor {
WeatherInteractorImpl(this._weatherRepository);
final WeatherRepository _weatherRepository;
@override
Future<double> getTemperature({required String city}) {
return _weatherRepository.getTemperature(city: city);
}
}
Since real WeatherRepository is doing http request, it’s best for this kind of tests to use Mockito to mock the repository. Mockiito gives us ability to swap a specific method that will be invoked later in the test. And with this we can test various cases that WeatherRepository could return.
To use mocks all you need to do is add repositories to @GenerateMocks([ *classes that you want to mock* ])
annotation above main method:
@GenerateMocks([WeatherRepository])
void main() {
After that run a build runner and it will generate a new file with .mocks.dart
suffix. Inside you’ll find MockWeatherRepository
class and bunch of generated code which you don’t need to care about, just import the file into your test file.
setUp and setUpAll
Mock classes need to be instantiated just like regular class. For this we recommend you create a field for it and instantiate in setUp
method. The setUp
method is callback that’s invoked before each test.
void main() {
late MockWeatherRepository weatherRepository;
/// setUp runs before every test
setUp(() {
weatherRepository = MockWeatherRepository();
}
... rest of the test file
With this setup we are sure that each test will get fresh MockWeatherRepository
and that no side-effects will leak from one test case that could affect other test cases.
There is also setUpAll
method which is invoked once before all tests in the file which can be useful for some cases.
Mocking the methods
If we go back, our interactor is invoking the _weatherRepository.getTemperature(city: city)
method. We need to mock it in order to create a test for it:
when(weatherRepository.getTemperature(city: anyNamed('city')))
.thenAnswer((_) async => 25);
With when
we register the method which will be called on invocation of weatherRepository.getTemperature
and return value of 25. With this way we swapped the method implementation. Full test would look like:
test('Test WeatherInteractorImpl returns correct temperature value', () async {
when(weatherRepository.getTemperature(city: anyNamed('city'))).thenAnswer((_) async => 25);
final sut = WeatherInteractorImpl(weatherRepository);
final temperature = await sut.getTemperature(city: 'London');
expect(temperature, 25);
});
Now this test passes when interactor successfully returns same value as it receives from repository. In this specific case, this interactor had no special logic associated to it. It just passed the data it receives from repository. There were no if, for loops or similar. So this test might looks meaningless. But we still want to write test for it. In future, maybe some developer will add additional logic to interactor, so with this way we can prevent any accidental changes. So, to repeat: try to test all interactors.
Testing the exceptions
If we want to test that some method throws an exception in some case then we can use throwsA
. For example you can test expect(method(), throwsA(anything))
.
Very often you will want to use throwsA
in combination with matcher isA
to exactly check for specific type of exception. Here’s a test example with it:
// When interactor receives the null, it will throw NoProfileException
when(profileRepository.getProfile()).thenAnswer((_) async => null);
final sut = ProfileInteractorImpl(profileRepository);
expect(sut.getProfile(), throwsA(isA<NoProfileException>()));
Important thing here is that you need to be careful not to throw the exception during the test which will fail the test. If we have following sync method:
String myMethod() {
throw NoProfileException();
}
then we cannot invoked myMethod()
anywhere in test otherwise the test fill crash because of the exception. So we cannot write expect(myMethod(), throws)
and instead we can just pass a pointer to the function expect(myMethod, throws)
and the expect will call that method at appropriate time.
For async method, story is similar. We must never await myAsyncMethod()
otherwise it will throw and fail test. We can write either expect(myAsyncMethod, throws)
or expect(myAsyncMethod(), throws)
since method is not run yet before someone calls await on it.
Advanced mocking with when
The when
method we explained has several other options. First thing that you’ve might seen is that you can control on which exactly parameters will the mock method invoke. With that way you can have two different mocks of the same method, and they will be invoked in different conditions. For example:
when(weatherRepository.getTemperature(city: 'London'))
.thenAnswer((_) async => 25);
when(weatherRepository.getTemperature(city: 'Cairo'))
.thenAnswer((_) async => 36);
Often what we do is we make a method get invoked for any kind of parameter it receives. For that we use any
or anyNamed
in case of named parameters:
when(weatherRepository.getTemperature(city: anyNamed('city')))
.thenAnswer((_) async => 25);
when(weatherRepository.getWindSpeed(any))
.thenAnswer((_) async => 45.0);
So these methods will be invoked no matter what kind of parameter for city
is sent to them.
Invocation
When mocking async methods as a parameter in thenAnswer
you will receive Invocation realInvoaction
which so far we dismissed as _
:
when(weatherRepository.getTemperature(city: anyNamed('city')))
.thenAnswer((realInvocation) async {
return 25;
}
);
The realInvocation
parameter contains original method invocation data. From it, you can for example read which parameters where passed to the method.
Here’s just one example of reading the parameter of type Function()
and then invoking that callback.
when(fuelPumpRepository.startFuelPump(onFuelPumpConnected: anyNamed('onFuelPumpConnected')))
.thenAnswer((realInvocation) {
final callback = realInvocation.namedArguments[const Symbol('onFuelPumpConnected')];
callback();
return mockData;
});
If you need you can check further details about mockito invocation: https://github.com/dart-lang/mockito/blob/master/lib/src/invocation_matcher.dart#L57