Test data generator for Dart ecosystem. Simplifies unit testing and Test-Driven Development.
This project is inspired by .NET Autofixture library.
While writing tests developers need some way to create random data. It starts from using Random
class to receive some int
/double
/bool
and continues to handwritten helper methods with a big amount of optional parameters to create complex object graphs. Helpers become more complex and require more maintenance while time passing. This makes them not flexible, but inferrable and annoying.
Activatory allows you to use ready-from-the-box class which can do the same as handwritten boilerplate code and don't waste your time on writing them.
Tests with activatory are:
- more readable because requires less boilerplate code;
- more sustainable to changes because all required objects in test data graph are created and wired automatically;
- more maintainable because all relevant logic is placed in test. No more complex helpers common for all tests;
- more flexible because activatory contains features that are hard to implement in handwritten helpers.
For more information please see "Why activatory does matter" section below.
To create activatory instance just call a constructor
import 'package:activatory/activatory.dart';
final activatory = new Activatory();
After it you can create almost every type of objects using activatory instance
final int randomInt = activatory.get<int>();
final AlmostAnyClass instance = activatory.get<AlmostAnyClass>();
Activatory can be used to create:
- random values of primitive types (String, int, double, bool, DateTime, enums, Null);
- random values of complex types using all Dart constructor types;
- List, Set, Iterable and Map;
- random values of recursive types (trees, linked lists and etc);
- random values of generic classes;
- any not listed above type with a bit setup code.
Any constructor arguments and public fields/setter values will be created, filled with random data and passed automatically.
Activatory support both styles of passing type parameters:
- with a generic class argument,
- with a regular
Type
object method parameter. In general, typed API is preferable because of generic code robustness and readability.
Suppose to use
final AlmostAnyClass instance = activatory.get<AlmostAnyClass>();
instead of
final AlmostAnyClass instance = activatory.getUntyped(AlmostAnyClass) as AlmostAnyClass;
Untyped API can be helpful in case when class type itself is resolved at runtime (for instance, it could be taken from array of types in foreach loop).
Activation behaviors can be customized in different ways.
One of the following situations can occur:
- creating of instance has some logic behind (e.g. filling with special values),
- type is not supported by activatory directly right now.
In this situation default factories can be replaced with explicit user defined function call by useFunction
method.
activatory.useFunction((ActivationContext ctx) => 42); // Imagine some logic behind 42 receiving, e.g. custom code.
final int goodNumber = activatory.get<int>(); // 42
The ActivationContext
is object that allows you to create any type instance inside callback.
activatory
..useFunction((ActivationContext ctx) => 42);
..useFunction((ActivationContext ctx) => ctx.get<int>().toString());
final int goodNumber = activatory.get<String>(); // "42"
If test scenario requires multiple instances of one class with the same value useSingleton
method can be used to pin single value for all activations of given type.
activatory.useSingleton(42);
final int fourtyTwo1 = activatory.get<int>(); // 42
final int fourtyTwo2 = activatory.get<int>(); // 42
This can be helpful if you test object graph contains repeating data, e.g. customer name, object ids, contacts and so on.
Suppose we are testing some single user scenario:
class ContactInfo { int id; String name;}
class ReportItem { ContactInfo contact; int amount; String itemName;}
...
final contact = new ContactInfo()
..id = 10 // or activatory.get<int>()
..name = 'Joe'; // or activatory.get<String>()
activatory.useSingleton(contact);
final List<ReportItem> report = activatory.getMany<ReportItem>();
final contactsCount = report.map((r) => r.contact).toSet().length; // 1
final reportItemsCount = report.map((r) => r.itemName).toSet().length; // 3
In this example activatory will create 3 instances of ReportItem
each of which will receive different values for itemName
. But all 3 instances will share one ContactInfo
instance.
If test scenario requires to pass one of predefined values as activation result useOneOf
method can be used. Also, useOneOf
is great to use with types, that have predefined list of instances, e.g. enum-like classes.
class Gender{
final String value;
const Gender._(this.value);
static const Gender male = Gender._('M');
static const Gender female = Gender._('W');
static const Gender other = Gender._('Unknown');
// Other members are skipped for brief
static const List<Gender> values => [male, female, other];
}
...
activatory.useOneOf(Gender.values);
final gender = activatory.get<Gender>();
gender.value; // M, W or Unknown
If a test scenario requires the same value for all type instances but there is no need to create it by hand useGeneratedSingleton
method can be used.
Let's rewrite sample below. In this code snipped we don't depend on contact id and name values, so them could be created without user defined code.
activatory.useGeneratedSingleton<ContactInfo>();
final List<ReportItem> report = activatory.getMany<ReportItem>();
final contactsCount = report.map((r) => r.contact).toSet().length; // 1
final reportItemsCount = report.map((r) => r.itemName).toSet().length; // 3
Existing configuration will be used while creating random value. Value is created during useGeneratedSingleton
call, so any following configuration will not affect it.
By default activatory takes first defined factory for class. This means first founded by reflection public constructor will be used if no override was provided. If any override was provided latest one will be used.
activatory.customize<MyClass>().resolvingStrategy = FactoryResolvingStrategy.TakeRandomNamedCtor;
Available strategies are:
TakeFirstDefined
(default one),TakeRandomNamedCtor
,TakeRandom
,TakeDefaultCtor
.
In this section SomeClassWithConstructors
class will be used as an example. Here is a definition.
class SomeClassWithConstructors {
final String value;
SomeClassWithConstructors.named1() : this('named1');
SomeClassWithConstructors(this.value);
SomeClassWithConstructors.named2() : this('named2');
}
TakeFirstDefined is the default strategy. This strategy will take the first available factory. If no overrides were provided will be used one of next:
- random value factory for primitive types;
- random value factory enums;
- fist defined constructor for complex type.
Example:
_activatory.customize<SomeClassWithConstructors>().resolvingStrategy = FactoryResolvingStrategy.TakeFirstDefined; // This line actually can be skipped
final firstDefinedCtorCallCount = _activatory.getMany<SomeClassWithConstructors>(count: 100).where((x) => x.value == 'named1').length; // 100
In this example firstDefinedCtorCallCount
will be equals 100 because first defined constructor (named as named1
) is used. This could looks strange, but it's common pattern to place more generic constructor first. Dart ecosystem even have a linter rule for constructor order validation. So usually first defined is more applicable to simulate random input.
If you override factory with any other latest one override will be used:
_activatory.useFunction((ctx) => new SomeClassWithConstructors('hello'));
final latestOverrideCallCount = _activatory.getMany<SomeClassWithConstructors>(count: 100).where((x) => x.value == 'hello').length; // 100
This behavior is used to match expected behavior on override call. So latestOverrideCallCount
will be equals 100.
This strategy takes random named ctor for complex type. If type doesn't have public named constructor or type is not complex ActivationException
will be thrown.
_activatory.customize<SomeClassWithConstructors>().resolvingStrategy = FactoryResolvingStrategy.TakeRandomNamedCtor;
final items = _activatory.getMany<SomeClassWithConstructors>(count: 100);
final firstNamedCtorCallsCount = items.where((x) => x.value == 'named1').length; // ~50
final secondNamedCtorCallsCount = items.where((x) => x.value == 'named2').length; // ~50
final totalCtorCallsCount = firstNamedCtorCallsCount + secondNamedCtorCallsCount; // 100
If you override factory with any other latest one override will be used:
_activatory.useFunction((ctx) => new SomeClassWithConstructors('hello'));
final overrideUsedCount = _activatory.getMany<SomeClassWithConstructors>(count: 100).where((x) => x.value == 'hello').length; // 100
This strategy will take a random available factory.
If no overrides are provided will be used:
- random value factory for primitive types;
- random value factory enums;
- random ctor for complex type.
_activatory.customize<SomeClassWithConstructors>().resolvingStrategy = FactoryResolvingStrategy.TakeRandom;
final items = _activatory.getMany<SomeClassWithConstructors>(count: 100);
final firstNamedCtorCallsCount = items.where((x) => x.value == 'named1').length; // ~33
final secondNamedCtorCallsCount = items.where((x) => x.value == 'named2').length; // ~33
final defaultCtorCallsCount = items.where((x) => !x.value.startsWith('named')).length; // ~33
final totalCtorCallCount = firstNamedCtorCallsCount + secondNamedCtorCallsCount + defaultCtorCallsCount; // 100
If overrides were provided random one will be chosen from overrides.
_activatory.useFunction((ctx) => new SomeClassWithConstructors('hello'));
final items2 = _activatory.getMany<SomeClassWithConstructors>(count: 100);
final overrideUsedCount = items2.where((x) => x.value == 'hello').length; // ~25
final firstNamedCtorCallsCount2 = items2.where((x) => x.value == 'named1').length; // ~25
final secondNamedCtorCallsCount2 = items2.where((x) => x.value == 'named2').length; // ~25
final defaultCtorCallsCount2 = items2.where((x) => x.value != 'hello' && !x.value.startsWith('named')).length; // ~25
final totalOverrideCallsCount = overrideUsedCount + firstNamedCtorCallsCount2 + secondNamedCtorCallsCount2 + defaultCtorCallsCount2; // 100
Take default ctor for complex type. Default ctor is the one called during evaluating new T()
expression. This can be factory, const or usual ctor. If type doesn't have public default ctor or type is not complex [ActivationException] will be thrown.
_activatory.customize<SomeClassWithConstructors>().resolvingStrategy = FactoryResolvingStrategy.TakeDefaultCtor;
final items = _activatory.getMany<SomeClassWithConstructors>(count: 100);
final defaultCtorCallsCount = items.where((x) => !x.value.startsWith('named')).length; // 100
If overrides were provided they will be ignored.
_activatory.useFunction((ctx) => new SomeClassWithConstructors('hello'));
final overrideUsedCount = _activatory.getMany<SomeClassWithConstructors>(count: 100).where((x) => x.value == 'hello').length; // 0
activatory.customize<MyClass>().defaultValuesHandlingStrategy = DefaultValuesHandlingStrategy.ReplaceAll;
Available strategies are:
ReplaceNulls
. Default values will be used while they not equalsnull
.ReplaceAll
. Value will be created and passed regardless default value.UseAll
. Default value will be used regardless it value.
activatory.customize<MyClass>().fieldsAutoFillingStrategy = FieldsAutoFillingStrategy.FieldsAndSetters;
Available strategies are:
Fields
(default). Public fields will be filled with random data;None
. Public fields and setters will be not filled;FieldsAndSetters
. Public fields and setters will be filled with random data.
The default used array size is 3.
final length1 = activatory.getMany<MyClass>().length; // 3
final length2 = activatory.get<List<MyClass>>().length; // 3
activatory.customize<MyClass>().arraySize = 100500;
final length3 = activatory.getMany<MyClass>().length; // 100500
final length4 = activatory.get<List<MyClass>>().length; // 100500
Please note that any activation of multiple instances of MyClass
will respect this configuration.
Suppose we need have a class that expect abstract class as constructor parameter.
abstract class User {}
class ReportItem { User user;}
In this case we can create subclass of User
and register it as substitution in activatory.
class VipUser extends User {}
...
activatory.replaceSupperClass<User, VipUser>();
final userType = activatory.get<ReportItem>().user.runtimeType; // VipUser
Customization can be defined on multiple levels:
- on global level for all types, using
activatory.defaultTypeCustomization
; - on type level, using
activatory.customize<T>()
; - on specific scope level by passing
key
argument (which can be an instance of any type) by callingactivatory.customize<T>(key: 'some key')
.Key
should be the same for all setup and activation calls to share setup.
Factories can't be overridden on a global level, only type and scope levels are supported.
Activatory allows to simplify test code by providing useful helpers.
- selecting one or more random items from array
final fromOneToFive = activatory.take([1, 2, 3, 4, 5]);
final threeItemsFromOneToFive = activatory.takeMany(3, [1, 2, 4, 5]);
- creating list of Objects of given type
final List<int> intArray = activatory.getMany<int>();
For up-to-date list of samples see example folder on project GitHub or example section on pub package page.
Current implementation depends on reflection (Mirrors package). So, Activatory supports only VM platform for now.
If you have a package depending on other platforms (e.g. Flutter or Dart Web) you should move testing required business logic to separate platform-independent package to be able to use Activatory in tests. Platform independent package of Activatory will be implemented in the future, but it will require much more setup from a user (due to Mirrors unavailability). Also, keep in mind, that moving business logic to a separate package is almost always a good decision.
For planned features and more see enhancements on github.
- Current version supports only VM platform. For more details see "Supported platforms" section above.
- Private classes (e.g. class with name prefixed by underscore) can't be activated without explicit setup due to private constructor unavailability for external package.
Suppose we are writing unit test for following sample classes
class UserDto {
String name;
int id;
bool isActive;
DateTime birthDate;
}
class UserId {
final int value;
UserId(this.value);
}
class UserViewModel {
final String name;
final UserId id;
final DateTime birthDate;
UserViewModel(this.name, this.id, this.birthDate);
}
abstract class UsersAPI {
Future<List<UserDto>> getAll();
}
class UsersManager {
final UsersAPI _api;
UsersManager(this._api);
Future<List<UserViewModel>> getActiveUsers() async {
final allItems = await _api.getAll();
return allItems.where((x) => x.isActive).map(_convert).toList(growable: false);
}
UserViewModel _convert(UserDto x) => new UserViewModel(x.name, new UserId(x.id), x.birthDate);
Future<UserViewModel> getById(UserId id) async {
final allItems = await _api.getAll();
final userDto = allItems.firstWhere((x) => x.id == id.value);
return _convert(userDto);
}
}
class _UsersAPIMock extends Mock implements UsersAPI {}
bool _isViewModelMatchUserDto(UserViewModel x, UserDto user) =>
x.id.value == user.id && x.name == user.name && x.birthDate == user.birthDate;
UserAPI
class implementation is skipped for brevity. Its contract includes one method returning all available UserDto
's. UserManager
is responsible for providing UserViewModel
to views.
_UsersAPIMock
is mock class created with mockito library.
_isViewModelMatchUserDto
is a helper for asserts, defined to brief samples.
Lets now write unit tests for UserManager
: one forgetById
method, another one for getAll
method.
group('Attempt #1: using Random class inside test', () {
Random _random;
_UsersAPIMock _apiMock;
UsersManager _manager;
setUp(() {
_random = new Random(DateTime.now().millisecondsSinceEpoch);
_apiMock = new _UsersAPIMock();
_manager = new UsersManager(_apiMock);
});
test('can find single user by id', () async {
// arrange
final userDtoItems = List.generate(
10,
(i) => new UserDto()
..id = i
..isActive = _random.nextBool()
..birthDate = new DateTime(
_random.nextInt(100) + 1900, //1900-2000
_random.nextInt(12),
_random.nextInt(29), // minimal count of days in month - 28
_random.nextInt(24),
_random.nextInt(60),
_random.nextInt(60),
)
..name = 'username $i',
);
final user = userDtoItems[_random.nextInt(10)];
final userId = new UserId(user.id);
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getById(userId);
// assert
expect(result, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
test('can find all active users', () async {
// arrange
final userDtoItems = List.generate(
10,
(i) => new UserDto()
..id = i
..isActive = false
..birthDate = new DateTime(
_random.nextInt(100) + 1900, //1900-2000
_random.nextInt(12),
_random.nextInt(29), // minimal count of days in month - 28
_random.nextInt(24),
_random.nextInt(60),
_random.nextInt(60),
)
..name = 'username $i',
);
final user = userDtoItems[_random.nextInt(10)];
user.isActive = true;
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getActiveUsers();
// assert
expect(result, hasLength(1));
expect(result.first, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
});
Obviously, there are a lot of duplication in this sample. Most of developers will try to extract useful helpers to reduce duplication.
group('Attempt #2: using handwriten helpers', () {
Random _random;
_UsersAPIMock _apiMock;
UsersManager _manager;
setUp(() {
_random = new Random(DateTime.now().millisecondsSinceEpoch);
_apiMock = new _UsersAPIMock();
_manager = new UsersManager(_apiMock);
});
UserDto _createRandomUserDto(int id, {bool isActive = false}) {
return new UserDto()
..id = id
..isActive = isActive ?? _random.nextBool()
..birthDate = new DateTime(
_random.nextInt(100) + 1900, //1900-2000
_random.nextInt(12),
_random.nextInt(29), // minimal count of days in month - 28
_random.nextInt(24),
_random.nextInt(60),
_random.nextInt(60),
)
..name = 'username $id';
}
test('can find single user by id', () async {
// arrange
final userDtoItems = List.generate(10, _createRandomUserDto);
final user = userDtoItems[_random.nextInt(10)];
final userId = new UserId(user.id);
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getById(userId);
// assert
expect(result, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
test('can find all active users', () async {
// arrange
final userDtoItems = List.generate(10, (i) => _createRandomUserDto(i, isActive: false));
final user = userDtoItems[_random.nextInt(10)];
user.isActive = true;
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getActiveUsers();
// assert
expect(result, hasLength(1));
expect(result.first, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
});
We move UserDto
instance creation logic to _createRandomUserDto
and arrange section is read much easier. This is good but not enough. Lets rewrite this test using Activatory.
With Activatory we can not waste our time and use out-of-the-box helpers to create random instances of objects.
group('Attempt #3: using Activatory', () {
Activatory _activatory;
_UsersAPIMock _apiMock;
UsersManager _manager;
setUp(() {
_activatory = new Activatory();
_apiMock = new _UsersAPIMock();
_manager = new UsersManager(_apiMock);
});
test('can find single item by id', () async {
// arrange
final userDtoItems = _activatory.getMany<UserDto>(count: 10);
final user = _activatory.take(userDtoItems);
final userId = new UserId(user.id);
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getById(userId);
// assert
expect(result, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
test('can find all active users', () async {
// arrange
final userDtoItems = _activatory.getMany<UserDto>(count: 10);
userDtoItems.forEach((x) => x.isActive = false);
final user = _activatory.take(userDtoItems);
user.isActive = true;
when(_apiMock.getAll()).thenAnswer((_) => Future.value(userDtoItems));
// act
final result = await _manager.getActiveUsers();
// assert
expect(result, hasLength(1));
expect(result.first, predicate<UserViewModel>((x) => _isViewModelMatchUserDto(x, user)));
});
});
No handwritten helpers are required. There is no need to write code describing how to create random instance of UserDto
.
With activatory test code becomes more readable, brief and maintainable.
But it is important to mention fact that tests are most useful when we are extending existing functionality.
Lets extends our UserDto
with extra data field UserSettingsDto
:
class UserDto {
// other fields skipped for brief
UserContactsDto userContacts;
}
class UserContactsDto {
String email;
bool notificationsEnabled;
}
class UserViewModel {
// other fields skipped for brief
final String email;
UserViewModel(this.name, this.id, this.birthDate, this.email);
}
class UsersManager {
// other members skipped for brief
UserViewModel _convert(UserDto x) => new UserViewModel(x.name, new UserId(x.id), x.birthDate, x.userContacts.email);
}
If we run tests now we will found that tests written without Activatory are failing with cryptic errors, e.g. NoSuchMethodError: The getter 'email' was called on null.
That's because we forgot to update our tests! In "Attemp 2" sample we need to update _createRandomUserDto
method implementation to create randomly filled UserSettingsDto
and pass it to UserDto
. So most of UserDto
extending will require the helper to be updated!
Activatory helps you to make your tests more reliable and more sustainable to changes.
Adding new data to models is just half of a story. Let's add complexity - another consumer of UsersAPI.
Let's add MailingRecipientsManager
class responsible for providing an email list for mailing.
class MailingRecipientsManager {
final UsersAPI _api;
MailingRecipientsManager(this._api);
Future<List<String>> getRecipientsList() async {
final items = await _api.getAll();
return items.map((x) => x.userContacts.email).toList(growable: false);
}
}
If we write tests for this class we will hit the following problems with _createRandomUserDto
helper:
- method is private for file,
- helper didn't allow to control creation of
UserSettingsDto
.
The first problem can be resolved by moving the helper method to a separate file named user_dto_helper.dart
. The second one can be resolved by passing UserSettingsDto
or email as a parameter to the helper. Both fixes will make our test code more and more unmaintainable. The helper will grow to match all corner cases. So in the future, the helper will become troublemaker - it will be changed for every business logic change, changes will flow from different branches and it will harder and harder to merge them.
Activatory helps you to make your tests code more flexible and maintainable.