Raise your productivity when writing Flutter apps with Pine bricks and Copilot
Do you believe me when I say you can create this simple Crypto App in less than an hour with good architecture and a minimum number of unit tests?
But let’s start from the beginning, what are Pine Bricks and Copilot?
Pine Bricks
Pine bricks are bricks based on the Pine scaffolding. If you never heard about Pine Architecture, here you can find more details about it.
I prefer defining it as an architecture because there’s a bit of software engineering stuff in it, but is a set of tools that helps you use Bloc for state management and Provider as a Dependency Injector/Service Locator following a predefined hierarchy while defining business logic in your app.
Bricks
But what are bricks? A brick is a template that helps you generate a boilerplate through Mason, a wonderful tool developed by Felix Angelov.
Don’t you feel bored writing the same code over and over? If you’re capable of finding some sort of pattern in your files, you can create your brick using mason_cli and share it with the community through brickhub.dev
Copilot
Do we need to introduce it? I’ll just stick with the GitHub definition.
GitHub Copilot suggests code completions as developers type and turns natural language prompts into coding suggestions based on the project’s context and style conventions.
Why these tools?
By combining these two tools, for some particular tasks, we can raise our productivity by over 300% and now I’ll explain why.
If you ever created a well-structured application, most of the time you’re going to write the same pieces over and over. Think about a repository or a service: you create an abstraction for the signature, you extend the abstraction with a concrete class, and then you put the logic inside of it. The same story applies when you want to create a Bloc: a bunch of events and states, a list of event subscriptions in the constructor, and the business logic inside of it.
Even copy-pasting the same code becomes time-consuming because you’re more prone to mistakes. A former professor of mine used to say that 50% of errors come from copying stuff.
To maximize our efficiency in writing boilerplate code we’re going to use bricks. Since I prefer using the Pine Architecture, I created a set of bricks for its scaffolding. With these bricks, you can create services, repositories, blocs, pages, models, DTOs, and so on.
Some of these bricks come with a set of fixtures to generate objects using fakers and they also generate boilerplate code to write unit tests.
Ok, now that we understood how to spend less time writing boilerplate code using Pine Bricks, what about GitHub Copilot?
Copilot is a wonderful tool when it comes to writing repetitive patterns in your code. I don’t like it when he tries to generate code when it doesn’t have enough context, that’s why I’m not using the prompt but the suggestion instead.
Where can you find repetitive patterns? Most of the time when you write CRUDs or tests. You’ll only need to write the first method of the class or the first test in the file and then Copilot will have enough context to understand what’s gonna change in the next iterations. With Copilot you can reduce your time writing tests by 60%, and the only thing you need to do is double-check that everything has been generated properly as Copilot is not always perfect.
Enough with the theory, let’s go writing our first application to see how to use Pine Bricks and Copilot to speed up our development.
Crypto App
Crypto App is one of the most developed applications you can find on GitHub or YouTube. It’s simple: you only need to perform some HTTP requests to an API to retrieve data you’re gonna show on your app.
Let’s create our Flutter Application with the following dependencies inside the pubspec.yaml. Some of these dependencies are mandatory if you want to use Pine Bricks, but I promise once you start using them, you’ll never come back.
dependencies:
auto_route: ^7.8.4
cached_network_image: ^3.3.1
dio: ^5.4.0
dio_smart_retry: ^6.0.0
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
flutter_essentials_kit: ^2.5.0
freezed_annotation: ^2.4.1
intl: ^0.18.1
json_annotation: ^4.8.1
pine: ^1.0.3
provider: ^6.1.1
retrofit: ^4.1.0
talker: ^4.0.1
talker_bloc_logger: ^4.0.1
talker_dio_logger: ^4.0.1
dev_dependencies:
flutter_test:
sdk: flutter
auto_route_generator: ^7.3.2
bloc_test: ^9.1.5
build_runner: ^2.4.8
data_fixture_dart: ^2.2.0
flutter_lints: ^3.0.1
freezed: ^2.4.7
http_mock_adapter: ^0.6.1
json_serializable: ^6.7.1
mockito: ^5.4.4
retrofit_generator: ^8.1.0
After getting these dependencies using flutter pub get, we must install mason_cli and our Pine Bricks. Let’s create a file in the root of the project named mason.yaml with the following content:
bricks:
pine: ^0.1.0+2
pine_bloc: ^0.2.0
pine_cubit: ^0.2.0
pine_dto_mapper: ^0.1.0+2
pine_model: ^0.1.0+2
pine_network_jto: ^0.1.0+2
pine_network_request: ^0.1.0+2
pine_network_response: ^0.1.0+2
pine_page: ^0.2.1
pine_repository: ^0.1.0+2
pine_retrofit: ^0.1.0+2
pine_service: ^0.1.0+2
We’re not gonna use them all in this project, but I suggest you install all of them when writing more complex applications. Now let’s run mason get in our prompt to retrieve the pine bricks. We can spot the mason-lock.json.
Setup the Dependency Injection
By following the Pine documentation, we need a class to define our global providers, repositories, mappers and blocs to be injected into the tree. Under lib/ we create a folder named di, and then a file named dependency_injector.dart.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pine/pine.dart';
import 'package:provider/provider.dart';
class DependencyInjector extends StatelessWidget {
final Widget child;
const DependencyInjector({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) => DependencyInjectorHelper(
providers: [],
mappers: [],
repositories: [],
child: child,
);
}
Let’s inject these items into the tree by adding the DependencyInjector widget on top of our application. We can create a file named app.dart which contains the following:
import 'package:crypto_app/di/dependency_injector.dart';
import 'package:crypto_app/features/routing/app_router.dart';
import 'package:crypto_app/features/theme/extensions/color_extension.dart';
import 'package:flutter/material.dart';
class App extends StatelessWidget {
final _router = AppRouter();
App({super.key});
@override
Widget build(BuildContext context) => DependencyInjector(
child: MaterialApp.router(
title: 'Crypto App',
debugShowCheckedModeBanner: false,
routeInformationParser: _router.defaultRouteParser(),
routerDelegate: _router.delegate(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
extensions: [
ColorExtension(
rising: Colors.green[800],
falling: Colors.red[800],
neutral: Colors.grey,
),
],
appBarTheme: const AppBarTheme(
centerTitle: true,
toolbarHeight: 80.0,
),
dividerTheme: const DividerThemeData(
thickness: 1.0,
indent: 16.0,
endIndent: 16.0,
),
),
),
);
}
And since we moved the App definition inside the above file, we can now simplify the main.dart as follows:
import 'package:crypto_app/app.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:talker_bloc_logger/talker_bloc_logger.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.leanBack);
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
Bloc.observer = TalkerBlocObserver();
runApp(App());
}
Be careful! I’m not gonna cover all the aspects of the Crypto App development process but I’ll focus on the Bricks and Copilot stuff only. I.e: if you don’t know where the class ColorExtension comes from, I encourage you to take a look at the project repository on GitHub.
CoinJTO to carry data from the API
In software engineering, to carry data across different layers you use a special model called DTO which stands for Data Transfer Object. In our Crypto App we need to get data from an API that answers to our HTTP requests with a JSON payload.
Most of the time our DTO carries JSON data, that’s why I decided to call it JTO which stands for JSON Transfer Object.
For our Crypto App we’re gonna use the Coingecko APIs to request a list of coins with EUR currency sorted descendingly by market capitalization:
{
"id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"image": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1696501400",
"current_price": 43177,
"market_cap": 849325665337,
"market_cap_rank": 1,
"fully_diluted_valuation": 908936212551,
"total_volume": 29370790007,
"high_24h": 43377,
"low_24h": 41403,
"price_change_24h": 1767.14,
"price_change_percentage_24h": 4.26748,
"market_cap_change_24h": 34932151048,
"market_cap_change_percentage_24h": 4.28935,
"circulating_supply": 19622762,
"total_supply": 21000000,
"max_supply": 21000000,
"ath": 59717,
"ath_change_percentage": -27.55324,
"ath_date": "2021-11-10T14:24:11.849Z",
"atl": 51.3,
"atl_change_percentage": 84235.76945,
"atl_date": "2013-07-05T00:00:00.000Z",
"roi": null,
"last_updated": "2024-02-09T09:19:18.204Z"
},
Now that we have a deep understanding of what kind of data the JTO will have, it’s time to generate it, let’s type the following in the command prompt:
mason make pine_network_jto --name "Coin"
We can spot two different files, the JTO under lib/services/network/jto/coin/coin_jto.dart and the Fixture of the JTO under test/fixtures/jto/coin_jto_fixture_factory.dart. It’s time to define the attributes of our JTO and how its fixture can generate one. The JTO relies on the freezed dependency, so keep in mind we’re gonna need it in our project. Don’t forget to run the build_runner in watch mode as freezed classes depend on code generation.
flutter pub run build_runner watch --delete-conflicting-outputs
Under lib/services/network/jto/coin/coin_jto.dart let’s define our JTO as follows:
import 'package:pine/pine.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'coin_jto.g.dart';
part 'coin_jto.freezed.dart';
@Freezed(toJson: false, copyWith: false)
class CoinJTO extends DTO with _$CoinJTO {
const factory CoinJTO({
@JsonKey(name: 'id') required String id,
@JsonKey(name: 'symbol') required String symbol,
@JsonKey(name: 'name') required String name,
@JsonKey(name: 'image') String? image,
@JsonKey(name: 'current_price') required num currentPrice,
@JsonKey(name: 'market_cap') required num marketCap,
@JsonKey(name: 'market_cap_rank') num? marketCapRank,
@JsonKey(name: 'fully_diluted_valuation') num? fullyDilutedValuation,
@JsonKey(name: 'total_volume') num? totalVolume,
@JsonKey(name: 'high_24h') num? high24h,
@JsonKey(name: 'low_24h') num? low24h,
@JsonKey(name: 'price_change_24h') num? priceChange24h,
@JsonKey(name: 'price_change_percentage_24h') required num priceChangePercentage24h,
@JsonKey(name: 'market_cap_change_24h') num? marketCapChange24h,
@JsonKey(name: 'market_cap_change_percentage_24h') num? marketCapChangePercentage24h,
@JsonKey(name: 'circulating_supply') num? circulatingSupply,
@JsonKey(name: 'total_supply') num? totalSupply,
@JsonKey(name: 'max_supply') num? maxSupply,
@JsonKey(name: 'ath') num? ath,
@JsonKey(name: 'ath_change_percentage') num? athChangePercentage,
@JsonKey(name: 'ath_date') String? athDate,
@JsonKey(name: 'atl') num? atl,
@JsonKey(name: 'atl_change_percentage') num? atlChangePercentage,
@JsonKey(name: 'atl_date') String? atlDate,
@JsonKey(name: 'last_updated') String? lastUpdated,
}) = _CoinJTO;
factory CoinJTO.fromJson(Map<String, dynamic> json) =>
_$CoinJTOFromJson(json);
}
Under test/fixtures/jto/coin_jto_fixture_factory.dart let’s define the CoinJTO Fixture taking advantage of Copilot. To generate our fixtures we can use the library data_fixture_dart.
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:data_fixture_dart/data_fixture_dart.dart';
extension CoinJTOFixture on CoinJTO {
static CoinJTOFixtureFactory factory() => CoinJTOFixtureFactory();
}
class CoinJTOFixtureFactory extends JsonFixtureFactory<CoinJTO> {
@override
FixtureDefinition<CoinJTO> definition() => define(
(faker) => CoinJTO(
id: faker.randomGenerator.string(10),
symbol: faker.currency.code(),
name: faker.currency.name(),
image: faker.internet.httpsUrl(),
currentPrice: faker.randomGenerator.decimal(),
marketCap: faker.randomGenerator.integer(1000),
marketCapRank: faker.randomGenerator.integer(10),
fullyDilutedValuation: faker.randomGenerator.integer(1000),
totalVolume: faker.randomGenerator.integer(1000),
high24h: faker.randomGenerator.decimal(),
low24h: faker.randomGenerator.decimal(),
priceChange24h: faker.randomGenerator.decimal(),
priceChangePercentage24h: faker.randomGenerator.decimal(),
marketCapChange24h: faker.randomGenerator.integer(1000),
marketCapChangePercentage24h: faker.randomGenerator.decimal(),
circulatingSupply: faker.randomGenerator.integer(1000),
totalSupply: faker.randomGenerator.integer(1000),
maxSupply: faker.randomGenerator.integer(1000),
ath: faker.randomGenerator.decimal(),
athChangePercentage: faker.randomGenerator.decimal(),
athDate: faker.date.dateTime().toIso8601String(),
atl: faker.randomGenerator.decimal(),
atlChangePercentage: faker.randomGenerator.decimal(),
atlDate: faker.date.dateTime().toIso8601String(),
lastUpdated: faker.date.dateTime().toIso8601String(),
),
);
@override
JsonFixtureDefinition<CoinJTO> jsonDefinition() => defineJson(
(object) => {
'id': object.id,
'symbol': object.symbol,
'name': object.name,
'image': object.image,
'current_price': object.currentPrice,
'market_cap': object.marketCap,
'market_cap_rank': object.marketCapRank,
'fully_diluted_valuation': object.fullyDilutedValuation,
'total_volume': object.totalVolume,
'high_24h': object.high24h,
'low_24h': object.low24h,
'price_change_24h': object.priceChange24h,
'price_change_percentage_24h': object.priceChangePercentage24h,
'market_cap_change_24h': object.marketCapChange24h,
'market_cap_change_percentage_24h':
object.marketCapChangePercentage24h,
'circulating_supply': object.circulatingSupply,
'total_supply': object.totalSupply,
'max_supply': object.maxSupply,
'ath': object.ath,
'ath_change_percentage': object.athChangePercentage,
'ath_date': object.athDate,
'atl': object.atl,
'atl_change_percentage': object.atlChangePercentage,
'atl_date': object.atlDate,
'last_updated': object.lastUpdated,
},
);
}
We can place our cursor next to the open curly bracket and Copilot will start generating suggestions. Sometimes it’s less accurate but it’s a good starting point to save some time writing this huge Fixture by hand.
Now that we have our JTO, it’s time to call the API
CoinService with Retrofit
Retrofit is one of my favorite Flutter libraries. It’s a Dio wrapper that simplifies the process of defining REST APIs in our projects. I created a brick to generate a Retrofit service, let’s run the following to create our CoinService:
mason make pine_retrofit --name "Coin"
We can spot two different files, the service under lib/services/network/coin/coin_service.dart and the test of the service under test/services/network/coin/coin_service_test.dart.
In the CoinService class we need to define our endpoints as follows:
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
part 'coin_service.g.dart';
/// Abstract class of CoinService
@RestApi()
abstract class CoinService {
factory CoinService(Dio dio, {String baseUrl}) = _CoinService;
@GET('/coins/markets')
Future<List<CoinJTO>> coins({
@Query('vs_currency') String currency = 'EUR',
@Query('order') String order = 'market_cap_desc',
@Query('per_page') int items = 250,
@Query('page') int page = 1,
@Query('parkline') bool parkLine = false,
});
}
Don’t forget to inject this service in the widget tree through our DependencyInjector widget.
Now that we have defined our service, we can create a bunch of tests. To mock my endpoints I like to use the http_mock_adapter library. We’re gonna use our fixtures to generate fake data for the library and to also make assertions on the output.
Here we can also take advantage of Copilot after it gains a bit of context. When you start defining these behaviors multiple times, you will only need to write the first test and then Copilot will suggest the other test cases:
import 'package:crypto_app/services/network/coin/coin_service.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:data_fixture_dart/misc/fixture_tuple.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../../../fixtures/jto/coin_jto_fixture_factory.dart';
/// Test case for the class CoinService
void main() {
late Dio dio;
late DioAdapter dioAdapter;
late CoinService service;
setUp(() {
dio = Dio(BaseOptions());
dioAdapter = DioAdapter(dio: dio);
service = CoinService(dio);
});
group('Testing coins endpoint', () {
late List<FixtureTuple<CoinJTO>> coins;
setUp(() {
coins = CoinJTOFixture.factory().makeManyWithJsonArray(5);
});
test('when /coins/markets answers 200 OK successfully', () async {
dioAdapter.onGet(
'/coins/markets',
(server) => server.reply(
200,
coins.map((coin) => coin.json).toList(),
),
);
final actual = coins.map((coin) => coin.object).toList();
expect(await service.coins(), actual);
});
test('when /coins/markets answers 422 Unprocessable Entity', () async {
dioAdapter.onGet(
'/coins/markets',
(server) => server.reply(422, null),
);
expect(
() async => await service.coins(),
throwsA(isA<DioException>()),
);
});
});
}
Now we can inject the service into the tree.
The Coin Model
What kind of information should we show in our application? This is the typical question we should ask ourselves to understand what attributes we need to extract from our JTOs to display data. Some of you may already be thinking why should we have all these layers of information when it’s way more simple to have a single model instead?
Well, the answer to our question comes from software engineering. Our data source may change over time, so it’s way simpler to change the behavior of a single mapper despite changing the whole business logic instead.
In our particular case, we’re not gonna show all the information we added in our JTO, it’s for the sake of this article to have all of them there. That’s why our Coin model will be slim.
Alright, it’s time to generate our model, we have a brick for it:
mason make pine_model --name "Coin"
Since the Coin shares some attributes with the CoinJTO class, it’s possible that Copilot will suggest something if we try to put our cursor next to the Coin constructor. If Copilot acts like a shy person, let’s do it on our own.
Under lib/models/coin/coin.dart:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'coin.freezed.dart';
@freezed
class Coin with _$Coin {
const Coin._();
const factory Coin({
required String id,
required String symbol,
required String name,
String? image,
required num currentPrice,
required num marketCap,
required num priceChangePercentage24h,
}) = _Coin;
String get formattedName => '$name (${symbol.toUpperCase()})';
bool get priceRising => priceChangePercentage24h > 0;
bool get priceFalling => priceChangePercentage24h < 0;
}
Don’t forget to define the Fixture of this model, it will be useful when we will mock data in our repository or bloc. Under test/fixtures/models/coin_fixture_factory.dart:
import 'package:crypto_app/models/coin/coin.dart';
import 'package:data_fixture_dart/data_fixture_dart.dart';
extension CoinFixture on Coin {
static CoinFixtureFactory factory() => CoinFixtureFactory();
}
class CoinFixtureFactory extends FixtureFactory<Coin> {
@override
FixtureDefinition<Coin> definition() => define(
(faker) => Coin(
id: faker.randomGenerator.string(10),
symbol: faker.currency.code(),
name: faker.currency.name(),
image: faker.internet.httpsUrl(),
currentPrice: faker.randomGenerator.decimal(),
marketCap: faker.randomGenerator.integer(1000),
priceChangePercentage24h: faker.randomGenerator.decimal(),
),
);
}
The CoinMapper
Now that we have our model, we‘ll take advantage of the mapper pattern to translate data from a JTO to a model and vice-versa. Of course, we have a brick for that:
mason make pine_dto_mapper --name "CoinMapper" --model_name "Coin" --dto_name "Coin" --type "jto"
After running our brick, we can spot two files, the mapper and its test. Let’s start defining the mapper behavior under lib/repositories/mappers/coin_mapper.dart. Since this is a monodirectional mapper (we use it only to transform our JTO to a model), we can ignore the toDTO method, but I’ll write it anyway for the sake of this article:
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:pine/pine.dart';
class CoinMapper extends DTOMapper<CoinJTO, Coin> {
const CoinMapper();
@override
Coin fromDTO(CoinJTO dto) => Coin(
id: dto.id,
symbol: dto.symbol,
name: dto.name,
image: dto.image,
currentPrice: dto.currentPrice,
marketCap: dto.marketCap,
priceChangePercentage24h: dto.priceChangePercentage24h,
);
@override
CoinJTO toDTO(Coin model) => CoinJTO(
id: model.id,
symbol: model.symbol,
name: model.name,
image: model.image,
currentPrice: model.currentPrice,
marketCap: model.marketCap,
priceChangePercentage24h: model.priceChangePercentage24h,
);
}
We can also take advantage of the Copilot suggestions to speed up our process:
The mapper test is already there, we simply need to create a Model and a JTO instance. Since the model has fewer attributes than our JTO, we cannot use the Fixtures to generate it. Under test/repositories/mappers/coin_mapper_test.dart:
import 'package:data_fixture_dart/data_fixture_dart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/repositories/mappers/coin_mapper.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
void main() {
late CoinMapper mapper;
late CoinJTO dto;
late Coin model;
setUp(() {
dto = CoinJTO(
id: faker.randomGenerator.string(10),
symbol: faker.currency.code(),
name: faker.currency.name(),
image: faker.internet.httpsUrl(),
currentPrice: faker.randomGenerator.decimal(),
marketCap: faker.randomGenerator.integer(1000),
priceChangePercentage24h: faker.randomGenerator.decimal(),
);
model = Coin(
id: dto.id,
symbol: dto.symbol,
name: dto.name,
image: dto.image,
currentPrice: dto.currentPrice,
marketCap: dto.marketCap,
priceChangePercentage24h: dto.priceChangePercentage24h,
);
mapper = const CoinMapper();
});
test('mapping Coin object from CoinJTO', () {
expect(mapper.fromDTO(dto), equals(model));
});
test('mapping Coin to CoinJTO', () {
expect(mapper.toDTO(model), equals(dto));
});
}
Now we can inject the mapper into the tree.
Data abstraction: the CoinRepository
It’s time to go back to our command prompt and type the following instructions:
mason make pine_repository --name "Coin"
The brick will generate two files, the repository and its test. The coin_repository.dart file now contains an abstraction called CoinRepository and a concrete implementation that implements the aforementioned class. Our goal now is to define the repository behaviors in the abstraction and implement them in the concrete class.
By defining our repositories in our project multiple times, Copilot will learn the context and will be able to make suggestions speeding up our development.
Under lib/repositories/coin_repository.dart:
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/services/network/coin/coin_service.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:pine/pine.dart';
import 'package:talker/talker.dart';
/// Abstract class of CoinRepository
abstract class CoinRepository {
Future<List<Coin>> get coins;
}
/// Implementation of the base interface CoinRepository
class CoinRepositoryImpl implements CoinRepository {
final CoinService coinService;
final DTOMapper<CoinJTO, Coin> coinMapper;
final Talker logger;
const CoinRepositoryImpl({
required this.coinService,
required this.coinMapper,
required this.logger,
});
@override
Future<List<Coin>> get coins async {
try {
logger.info('Fetching coins');
final jtos = await coinService.coins();
logger.info('Coins fetched');
return jtos.map(coinMapper.fromDTO).toList(growable: false);
} catch (error, stackTrace) {
logger.error('Failed to fetch coins', error, stackTrace);
rethrow;
}
}
}
After defining the repository behaviors, it’s time to test them. Head back to test/repositories/coin/coin_repository_test.dart to create our test suites. We’ll take advantage of the mockito library to mock our service and our mapper, and we’ll use the Fixture to fake the data. After writing the happy path test case, Copilot should be smart enough to create the other ones on its own, helping us save more time.
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/repositories/coin_repository.dart';
import 'package:crypto_app/repositories/mappers/coin_mapper.dart';
import 'package:crypto_app/services/network/coin/coin_service.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:talker/talker.dart';
import '../../fixtures/models/coin_fixture_factory.dart';
import 'coin_repository_test.mocks.dart';
/// Test case for the class CoinRepositoryImpl
@GenerateMocks([
CoinService,
CoinMapper,
], customMocks: [
MockSpec<Talker>(unsupportedMembers: {#configure})
])
void main() {
late MockCoinService service;
late MockCoinMapper mapper;
late MockTalker logger;
late CoinRepository repository;
setUp(() {
service = MockCoinService();
mapper = MockCoinMapper();
logger = MockTalker();
repository = CoinRepositoryImpl(
coinService: service,
coinMapper: mapper,
logger: logger,
);
});
group('Testing the coins getter', () {
late List<Coin> coins;
late List<CoinJTO> jtos;
setUp(() {
coins = CoinFixture.factory().makeMany(5);
jtos = coins
.map((coin) => CoinJTO(
id: coin.id,
symbol: coin.symbol,
name: coin.name,
image: coin.image,
currentPrice: coin.currentPrice,
marketCap: coin.marketCap,
priceChangePercentage24h: coin.priceChangePercentage24h,
))
.toList(growable: false);
});
test('get coins successfully', () async {
when(service.coins()).thenAnswer((_) async => jtos);
for (var i = 0; i < coins.length; i++) {
when(mapper.fromDTO(jtos[i])).thenReturn(coins[i]);
}
expect(await repository.coins, coins);
verify(service.coins()).called(1);
for (var i = 0; i < coins.length; i++) {
verify(mapper.fromDTO(jtos[i])).called(1);
}
});
test('get coins with an unexpected error', () async {
when(service.coins()).thenThrow(Exception());
expect(() async => await repository.coins, throwsException);
verify(service.coins()).called(1);
});
});
}
Now we can inject the repository into the tree.
Logic: The BLoC
Here come my two favorite bricks pine_bloc and pine_cubit. They are fabulous: by defining a comma-separated set of events and states I can generate the whole bloc/cubit boilerplate with a single instruction. Let’s do it for our CoinBloc as follows:
mason make pine_bloc --name "Coin" --events "fetch" --states "initial,fetching,fetched,none,errorFetching"
Under lib/blocs/coin/coin_state.dart we need to fill the attributes for our states, the fetch event doesn’t need anything:
part of 'coin_bloc.dart';
@freezed
class CoinState with _$CoinState {
const factory CoinState.initial() = InitialCoinState;
const factory CoinState.fetching() = FetchingCoinState;
const factory CoinState.fetched(List<Coin> coins) = FetchedCoinState;
const factory CoinState.none() = NoneCoinState;
const factory CoinState.errorFetching(dynamic error) = ErrorFetchingCoinState;
}
Under lib/blocs/coin/coin_bloc.dart let’s wrap up:
import 'dart:async';
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/repositories/coin_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'coin_bloc.freezed.dart';
part 'coin_event.dart';
part 'coin_state.dart';
/// The CoinBloc
class CoinBloc extends Bloc<CoinEvent, CoinState> {
final CoinRepository coinRepository;
/// Create a new instance of [CoinBloc].
CoinBloc({
required this.coinRepository,
}) : super(const CoinState.initial()) {
on<FetchCoinEvent>(_onFetch);
}
/// Method used to add the [FetchCoinEvent] event
void fetch() => add(const CoinEvent.fetch());
FutureOr<void> _onFetch(
FetchCoinEvent event,
Emitter<CoinState> emit,
) async {
try {
emit(const CoinState.fetching());
final coins = await coinRepository.coins;
emit(
coins.isNotEmpty ? CoinState.fetched(coins) : const CoinState.none(),
);
} catch (error) {
emit(CoinState.errorFetching(error));
}
}
}
And now it’s time for the test. This brick is magical, after you define the first test case, when you put your cursor on the next line, Copilot gains so much context it can enumerate the missing states to suggest the missing scenarios. Of course, it’s not as accurate as it should be, but with literally zero effort we can fix the errors in a few seconds.
Under test/blocs/coin/coin_bloc_test.dart let’s complete the file as follows:
import 'package:bloc_test/bloc_test.dart';
import 'package:crypto_app/blocs/coin/coin_bloc.dart';
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/repositories/coin_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../fixtures/models/coin_fixture_factory.dart';
import 'coin_bloc_test.mocks.dart';
@GenerateMocks([CoinRepository])
void main() {
late MockCoinRepository coinRepository;
late CoinBloc bloc;
setUp(() {
coinRepository = MockCoinRepository();
bloc = CoinBloc(coinRepository: coinRepository);
});
/// Testing the event [FetchCoinEvent]
group('when the event FetchCoinEvent is added to the BLoC', () {
late List<Coin> coins;
late dynamic exception;
setUp(() {
coins = CoinFixture.factory().makeMany(3);
exception = Exception();
});
blocTest<CoinBloc, CoinState>(
'test that CoinBloc emits CoinState.fetched when fetch is called',
setUp: () {
when(coinRepository.coins).thenAnswer((_) async => coins);
},
build: () => bloc,
act: (bloc) {
bloc.fetch();
},
expect: () => <CoinState>[
const CoinState.fetching(),
CoinState.fetched(coins),
],
verify: (_) {
verify(coinRepository.coins).called(1);
},
);
blocTest<CoinBloc, CoinState>(
'test that CoinBloc emits CoinState.errorFetching when fetch is called and an error occurs',
setUp: () {
when(coinRepository.coins).thenThrow(exception);
},
build: () => bloc,
act: (bloc) {
bloc.fetch();
},
expect: () => <CoinState>[
const CoinState.fetching(),
CoinState.errorFetching(exception),
],
verify: (_) {
verify(coinRepository.coins).called(1);
},
);
blocTest<CoinBloc, CoinState>(
'test that CoinBloc emits CoinState.none when fetch is called',
setUp: () {
when(coinRepository.coins).thenAnswer((_) async => []);
},
build: () => bloc,
act: (bloc) {
bloc.fetch();
},
expect: () => <CoinState>[
const CoinState.fetching(),
const CoinState.none(),
],
verify: (_) {
verify(coinRepository.coins).called(1);
},
);
});
}
Dependency Injection
We’re not gonna inject the Bloc on the tree since this is a scoped Bloc, this is how the DependencyInjector class should look at the end of the project:
import 'package:crypto_app/misc/constants.dart';
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/repositories/coin_repository.dart';
import 'package:crypto_app/repositories/mappers/coin_mapper.dart';
import 'package:crypto_app/services/network/coin/coin_service.dart';
import 'package:crypto_app/services/network/jto/coin/coin_jto.dart';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pine/pine.dart';
import 'package:provider/provider.dart';
import 'package:talker/talker.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
class DependencyInjector extends StatelessWidget {
final Widget child;
const DependencyInjector({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) => DependencyInjectorHelper(
providers: [
Provider<Dio>(
create: (_) {
final dio = Dio(BaseOptions(
connectTimeout: K.networkTimeout,
sendTimeout: K.networkTimeout,
receiveTimeout: K.networkTimeout,
));
dio.interceptors.addAll([
RetryInterceptor(
dio: dio,
retries: 3,
retryDelays: const [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 3),
],
),
if (kDebugMode)
TalkerDioLogger(
settings: const TalkerDioLoggerSettings(
printRequestHeaders: true,
printResponseHeaders: true,
printResponseMessage: true,
),
),
]);
return dio;
},
),
Provider<Talker>(
create: (_) => Talker(),
),
Provider<CoinService>(
create: (context) => CoinService(
context.read(),
baseUrl: K.baseUrl,
),
),
],
mappers: [
Provider<DTOMapper<CoinJTO, Coin>>(
create: (_) => const CoinMapper(),
),
],
repositories: [
RepositoryProvider<CoinRepository>(
create: (context) => CoinRepositoryImpl(
coinService: context.read(),
coinMapper: context.read(),
logger: context.read(),
),
),
],
child: child,
);
}
The UI: CoinsPage
Yes, I also have a brick for that. Of course, I rely on auto_route to manage routing in my Flutter apps, so if you want to use this brick you have to use it too. When running the following command I’m setting the state to false since I need a stateless page but the auto_route is set to true as I’m gonna take advantage of a special mixin to inject the CoinBloc locally.
mason make pine_page --name "Coins" --state false --auto_route true
What does the CoinsPage look like? Under lib/pages/coins_page.dart we have the following:
import 'package:auto_route/auto_route.dart';
import 'package:crypto_app/blocs/coin/coin_bloc.dart';
import 'package:crypto_app/widgets/coin_tile.dart';
import 'package:crypto_app/widgets/loading_widget.dart';
import 'package:crypto_app/widgets/no_coins_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Enter the Coins documentation here
@RoutePage()
class CoinsPage extends StatelessWidget implements AutoRouteWrapper {
/// The constructor of the page.
const CoinsPage({super.key});
@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider<CoinBloc>(
create: (context) => CoinBloc(
coinRepository: context.read(),
)..fetch(),
),
],
child: this,
);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Crypto App')),
body: BlocBuilder<CoinBloc, CoinState>(
builder: (context, state) => switch (state) {
FetchingCoinState() => const LoadingWidget(),
FetchedCoinState(:final coins) => ListView.separated(
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
final coin = coins[index];
return CoinTile(coin);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: coins.length,
),
NoneCoinState() => NoCoinsWidget(
onPressed: () => context.read<CoinBloc>().fetch(),
),
ErrorFetchingCoinState() => NoCoinsWidget(
onPressed: () => context.read<CoinBloc>().fetch(),
),
_ => const SizedBox.shrink(),
},
),
);
}
Can you tell I’m obsessed with tests? No? Here’s the test for the CoinsPage class under test/pages/coins/coins_page_test.dart
import 'dart:io';
import 'package:bloc_test/bloc_test.dart';
import 'package:crypto_app/blocs/coin/coin_bloc.dart';
import 'package:crypto_app/models/coin/coin.dart';
import 'package:crypto_app/pages/coins_page.dart';
import 'package:crypto_app/widgets/coin_tile.dart';
import 'package:crypto_app/widgets/no_coins_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pine/pine.dart';
import '../../fixtures/models/coin_fixture_factory.dart';
/// Test case for the page Coins
void main() {
late MockCoinBloc coinBloc;
late List<BlocProvider> blocs;
late List<Coin> coins;
setUpAll(() => HttpOverrides.global = null);
setUp(() {
coinBloc = MockCoinBloc();
blocs = [
BlocProvider<CoinBloc>.value(value: coinBloc),
];
coins = CoinFixture.factory().makeMany(3);
});
testWidgets(
'Testing that CoinsPage with a list of coins is rendered properly',
(tester) async {
whenListen(
coinBloc,
Stream.fromIterable([
const FetchingCoinState(),
FetchedCoinState(coins),
]),
initialState: const FetchingCoinState(),
);
await tester.pumpWidget(
DependencyInjectorHelper(
blocs: blocs,
child: const MaterialApp(
home: CoinsPage(),
),
),
);
expect(find.text('Crypto App'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(ListView), findsNothing);
expect(find.byType(NoCoinsWidget), findsNothing);
expect(find.byType(CoinTile), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(ListView), findsOneWidget);
expect(find.byType(NoCoinsWidget), findsNothing);
expect(find.byType(CoinTile), findsNWidgets(coins.length));
});
testWidgets('Testing that CoinsPage with no coins is rendered properly',
(tester) async {
whenListen(
coinBloc,
Stream.fromIterable([
const FetchingCoinState(),
const NoneCoinState(),
]),
initialState: const FetchingCoinState(),
);
await tester.pumpWidget(
DependencyInjectorHelper(
blocs: blocs,
child: const MaterialApp(
home: CoinsPage(),
),
),
);
expect(find.text('Crypto App'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(ListView), findsNothing);
expect(find.byType(NoCoinsWidget), findsNothing);
expect(find.byType(CoinTile), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(ListView), findsNothing);
expect(find.byType(NoCoinsWidget), findsOneWidget);
expect(find.byType(CoinTile), findsNothing);
});
testWidgets('Testing that CoinsPage with an error is rendered properly',
(tester) async {
whenListen(
coinBloc,
Stream.fromIterable([
const FetchingCoinState(),
ErrorFetchingCoinState(Exception()),
]),
initialState: const FetchingCoinState(),
);
await tester.pumpWidget(
DependencyInjectorHelper(
blocs: blocs,
child: const MaterialApp(
home: CoinsPage(),
),
),
);
expect(find.text('Crypto App'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(ListView), findsNothing);
expect(find.byType(NoCoinsWidget), findsNothing);
expect(find.byType(CoinTile), findsNothing);
await tester.pumpAndSettle();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(ListView), findsNothing);
expect(find.byType(NoCoinsWidget), findsOneWidget);
expect(find.byType(CoinTile), findsNothing);
});
}
class MockCoinBloc extends MockBloc<CoinEvent, CoinState> implements CoinBloc {}
Copilot is so helpful in these particular scenarios, it can suggest different use cases and it modulates the assertions accordingly
Final thoughts
Looks like we finished our application. It took me more time to write this article than to create this simple project with good architecture and unit testing 😂.
Are you curious about it? Do you want to take a look at the full source code? Here’s the repository on GitHub: https://github.com/AngeloAvv/crypto_app
Thank you for reading 👋
I hope you enjoyed this article. If you have any questions or suggestions please let me know in the comments down below.