Unit Testing vs. Widget Testing vs. Integration Testing
What each kind of Flutter test proves, what it costs, and how to combine them into a test suite you can trust.
Introduction
Most teams agree that tests matter, but many still argue about which tests to write. Write only one kind and you end up with a suite that is either fast but blind, or thorough but so slow and flaky that nobody runs it.
This article answers three questions: what should I learn, how should I implement it, and why does it matter in production?
By the end of this article, you will know:
- What unit tests, widget tests, and integration tests each check, and where the line between them sits.
- How to write a unit test for shopping cart rules, using plain Dart classes.
- How to write widget tests for a cart screen with a fake repository, so they run fast and give the same result every time.
- How to write an integration test for the full checkout flow, and how to choose the right mix of all three.
The problem: a small change breaks checkout
Imagine you work on an e-commerce app. Users browse products, add them to a cart, apply a coupon, and tap Checkout. One afternoon a developer changes how coupon discounts are rounded. The change looks harmless, the code review is quick, and the release goes out.
Two days later, support tickets arrive. Some carts show a total that is one cent off from the payment receipt. A few users on small phones cannot see the Checkout button at all, because a new promo banner pushed it off the screen. Nobody noticed, because the only “test” was a developer tapping through the app on a large emulator.
The team decides to “add tests”. But which ones? One developer wants to test everything through the real app on a device. Another wants hundreds of tiny tests for each function. Both are partly right, and both will be disappointed if they pick only one kind.
The common mistake: testing everything through one big screen
The easy-looking approach is to put the cart logic directly inside the widget and call the API from there. It works on the first day, and it seems simple:
class CartScreen extends StatefulWidget {
const CartScreen({super.key});
@override
State<CartScreen> createState() => _CartScreenState();
}
class _CartScreenState extends State<CartScreen> {
final _api = ApiClient(baseUrl: 'https://api.shop.com');
List<Map<String, dynamic>> _items = [];
double _total = 0;
@override
void initState() {
super.initState();
_api.get('/cart').then((json) {
setState(() {
_items = List<Map<String, dynamic>>.from(json['items']);
_total = _items.fold(0.0, (sum, item) => sum + item['price'] * item['qty']);
_total = _total * 0.9; // 10% coupon, hard-coded
});
});
}
@override
Widget build(BuildContext context) {
// ... list of items, total, and a checkout button
}
}Now try to test this screen. You quickly find that you cannot test anything without the whole world around it:
- The rules are trapped in the UI. To check that a 10% coupon is calculated correctly, you must build a widget, wait for a network call, and read text from the screen.
- The test needs a real server. The widget creates its own
ApiClient, so there is no way to give it test data. The test is slow and fails whenever the network or the backend is down. - Money is stored as a double. Floating-point maths creates the “one cent off” bug, and nothing in the design makes that easy to catch.
- Failures are hard to read. When the one big test fails, you do not know if the bug is in the maths, the layout, or the API.
- The team stops running the tests. Slow, flaky tests get skipped, and soon they are deleted.
The technical explanation: three tests, three different questions
Think of a restaurant kitchen. Before a dish reaches a guest, the chef checks each ingredient (is the sauce salty enough?), then tastes the finished plate (does this dish look and taste right?), and from time to time runs a full dinner service with real waiters and real tables (does the whole evening work?). Each check answers a different question. You would not run a full dinner service just to taste the sauce.
Get AK IJ’s stories in your inbox
Join Medium for free to get updates from this writer.
Flutter tests work the same way:
- Unit test: checks one function, method, or class in isolation. No widgets, no device, no network. It answers: “Is this rule correct?” Example: the cart total after a 10% coupon.
- Widget test: builds one widget or screen in a test environment, without a real device. You can tap, scroll, and type, then check what is shown. It answers: “Does this screen show the right thing and react correctly?” Example: the Checkout button is disabled when the cart is empty.
- Integration test: runs the whole app on a real device or emulator, with real plugins and navigation. It answers: “Does the full user journey work?” Example: browse, add to cart, check out, see the confirmation.
A few terms you will see in the code below:
- Fake: a simple, working replacement for a real dependency, such as a repository that returns fixed data instead of calling the API.
- Flaky test: a test that sometimes passes and sometimes fails without any code change, usually because of timing, network, or shared state.
- pump: in widget tests,
tester.pump()asks Flutter to build one new frame.pumpAndSettle()keeps building frames until all animations are finished. - Finder: a way to locate a widget in the tree, such as
find.text('Checkout')orfind.byKey(...).
These three kinds of tests are often drawn as a pyramid: many fast unit tests at the bottom, a good number of widget tests in the middle, and a few slow integration tests at the top.
The pyramid is not a strict rule. It is a reminder of cost. Each step up gives you more confidence that real users are fine, but it also takes longer to run, needs more setup, and is harder to debug when it fails.
The implementation: testing a shopping cart at every level
To make the three kinds of tests possible, the code needs clear boundaries. The rules live in plain Dart, the screen reads from a controller, and the controller talks to a repository that we can replace in tests. The diagram below shows which parts each test touches.
Step 1: Move the cart rules into plain Dart classes
class CartItem {
const CartItem({
required this.id,
required this.name,
required this.priceCents,
required this.quantity,
});
final String id;
final String name;
final int priceCents;
final int quantity;
int get lineTotalCents => priceCents * quantity;
}
class Cart {
const Cart({this.items = const [], this.couponPercent = 0});
final List<CartItem> items;
final int couponPercent;
int get subtotalCents =>
items.fold(0, (sum, item) => sum + item.lineTotalCents);
int get discountCents => subtotalCents * couponPercent ~/ 100;
int get totalCents => subtotalCents - discountCents;
bool get canCheckout => items.isNotEmpty;
Cart applyCoupon(int percent) {
if (percent < 0 || percent > 100) {
throw ArgumentError.value(percent, 'percent', 'must be 0 to 100');
}
return Cart(items: items, couponPercent: percent);
}
}
abstract interface class CartRepository {
Future<List<CartItem>> fetchItems();
Future<int> validateCoupon(String code);
Future<String> placeOrder(Cart cart);
}The Cart class knows nothing about Flutter, HTTP, or the screen. Prices are stored as whole cents in an int, which removes the floating-point rounding bug. CartRepository is an interface: the real app uses an API-backed version, and tests can use a fake one.
Step 2: Unit test the rules
import 'package:flutter_test/flutter_test.dart';
import 'package:shop/cart/cart.dart';
void main() {
const shoes = CartItem(
id: 'p1', name: 'Running shoes', priceCents: 5000, quantity: 2);
const socks =
CartItem(id: 'p2', name: 'Socks', priceCents: 1000, quantity: 1);
group('Cart', () {
test('subtotal adds every line', () {
const cart = Cart(items: [shoes, socks]);
expect(cart.subtotalCents, 11000);
});
test('coupon reduces the total', () {
final cart = const Cart(items: [shoes, socks]).applyCoupon(10);
expect(cart.discountCents, 1100);
expect(cart.totalCents, 9900);
});
test('an empty cart cannot check out', () {
expect(const Cart().canCheckout, isFalse);
});
test('rejects a coupon above 100 percent', () {
expect(() => const Cart().applyCoupon(150), throwsArgumentError);
});
});
}These tests run in milliseconds and need no device. Each one checks one rule, so when a test fails, its name tells you exactly what broke. This is where you test edge cases: empty carts, large quantities, 0% and 100% coupons, and invalid input.
Step 3: Build the screen on top of a controller
import 'package:flutter/material.dart';
class CartController extends ChangeNotifier {
CartController(this._repository);
final CartRepository _repository;
Cart cart = const Cart();
bool isLoading = false;
String? errorMessage;
Future<void> load() async {
isLoading = true;
notifyListeners();
try {
cart = Cart(items: await _repository.fetchItems());
errorMessage = null;
} catch (_) {
errorMessage = 'Could not load your cart.';
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> applyCoupon(String code) async {
try {
final percent = await _repository.validateCoupon(code);
cart = cart.applyCoupon(percent);
errorMessage = null;
} catch (_) {
errorMessage = 'This coupon is not valid.';
}
notifyListeners();
}
}
String formatCents(int cents) => '\$${(cents / 100).toStringAsFixed(2)}';
class CartScreen extends StatelessWidget {
const CartScreen({
super.key,
required this.controller,
required this.onCheckout,
});
final CartController controller;
final VoidCallback onCheckout;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Your cart')),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final cart = controller.cart;
return Column(
children: [
if (controller.errorMessage case final message?)
Text(message, key: const Key('cart-error')),
Expanded(
child: ListView(
children: [
for (final item in cart.items)
ListTile(
title: Text(item.name),
trailing: Text('x${item.quantity}'),
),
],
),
),
Text(formatCents(cart.totalCents), key: const Key('cart-total')),
FilledButton(
key: const Key('checkout-button'),
onPressed: cart.canCheckout ? onCheckout : null,
child: const Text('Checkout'),
),
],
);
},
),
);
}
}The screen receives its controller and its checkout action from outside. It does not create an API client, and it does not do any maths itself. The Key values give tests a stable way to find important widgets, even if the text or layout changes later.
Step 4: Widget test the screen with a fake repository
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class FakeCartRepository implements CartRepository {
FakeCartRepository({this.items = const [], this.coupons = const {}});
final List<CartItem> items;
final Map<String, int> coupons;
@override
Future<List<CartItem>> fetchItems() async => items;
@override
Future<int> validateCoupon(String code) async =>
coupons[code] ?? (throw StateError('Unknown coupon'));
@override
Future<String> placeOrder(Cart cart) async => 'order-1';
}
const shoes = CartItem(
id: 'p1', name: 'Running shoes', priceCents: 5000, quantity: 2);
Future<CartController> pumpCart(
WidgetTester tester,
FakeCartRepository repository, {
VoidCallback? onCheckout,
}) async {
final controller = CartController(repository);
await controller.load();
await tester.pumpWidget(MaterialApp(
home: CartScreen(controller: controller, onCheckout: onCheckout ?? () {}),
));
return controller;
}
void main() {
testWidgets('shows items and the total', (tester) async {
await pumpCart(tester, FakeCartRepository(items: const [shoes]));
expect(find.text('Running shoes'), findsOneWidget);
expect(find.text('\$100.00'), findsOneWidget);
});
testWidgets('checkout is disabled for an empty cart', (tester) async {
await pumpCart(tester, FakeCartRepository());
final button = tester.widget<FilledButton>(
find.byKey(const Key('checkout-button')),
);
expect(button.onPressed, isNull);
});
testWidgets('an invalid coupon shows an error', (tester) async {
final controller =
await pumpCart(tester, FakeCartRepository(items: const [shoes]));
await controller.applyCoupon('NOPE');
await tester.pump();
expect(find.byKey(const Key('cart-error')), findsOneWidget);
});
testWidgets('tapping checkout starts the checkout', (tester) async {
var started = false;
await pumpCart(
tester,
FakeCartRepository(items: const [shoes]),
onCheckout: () => started = true,
);
await tester.tap(find.byKey(const Key('checkout-button')));
expect(started, isTrue);
});
}Widget tests are where UI behaviour gets checked: what is visible, what is disabled, what happens after a tap, and how errors appear. The fake repository returns data instantly, so there is no network and no waiting. You can also set tester.view.physicalSize to a small screen size to check that the Checkout button still fits on a small phone, which is exactly the layout bug from our problem.
Step 5: Integration test the full checkout journey
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:shop/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('a user can buy a product', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.text('Running shoes'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('add-to-cart')));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.shopping_cart));
await tester.pumpAndSettle();
expect(find.text('Running shoes'), findsOneWidget);
await tester.tap(find.byKey(const Key('checkout-button')));
await tester.pumpAndSettle();
expect(find.text('Order confirmed'), findsOneWidget);
});
}This test starts the real app on a device or emulator with flutter test integration_test/checkout_test.dart. It uses real navigation, real plugins, and the real dependency setup. Point it at a test or staging backend (for example with --dart-define), never at production. Keep integration tests few and focused on the journeys that make or lose money: sign in, add to cart, and pay.
The trade-offs: confidence versus cost
Each kind of test is a trade between how much it proves and how much it costs to write, run, and maintain.
- Unit tests are cheap and very precise, but they cannot tell you if the screen shows the result or if the button is reachable. Use them for every business rule, calculation, mapping, and validation.
- Widget tests cover most UI behaviour at a fraction of the cost of a device test. They do not run real platform code (camera, payments SDK, push notifications), so they cannot prove those parts work.
- Integration tests give the strongest proof that users can finish a task, but they are slow, need a device, and are the most likely to become flaky. A failing integration test tells you that something is broken, but rarely where.
When should you keep it simpler? A small app with little logic may not need a deep pyramid. A handful of widget tests for the main screens and one integration test for the main flow can be enough. Do not write unit tests for code that has no logic, such as a widget that only shows a label, and do not mock every class just to reach a coverage number. Test behaviour that users or the business care about, at the lowest level that can prove it.
A useful rule of thumb: if a bug can be caught by a unit test, catch it there. If it needs a screen, use a widget test. Save integration tests for things that only break when the whole app runs together.
A checklist for choosing the right test
- Keep business rules in plain Dart classes, so they can be unit tested without Flutter.
- Pass dependencies such as repositories into controllers and screens, so tests can use fakes.
- Write a unit test for every rule that handles money, dates, validation, or permissions.
- Write widget tests for loading, empty, error, and success states of each important screen.
- Add
Keyvalues to widgets that tests need to find reliably. - Limit integration tests to a few critical user journeys, and run them against a test backend.
- Run unit and widget tests on every pull request; run integration tests before every release.
The takeaway
- Each test answers a different question. Unit tests check rules, widget tests check screens, and integration tests check full journeys.
- Design makes testing possible. When rules live in plain classes and dependencies are passed in, every kind of test becomes easy to write.
- Fakes keep tests fast and stable. A fake repository removes the network and makes every run give the same result.
- Use the lowest level that can catch the bug. It is faster to run and much easier to debug when it fails.
- Integration tests are precious, not plentiful. Keep a few for the flows that matter most to users and the business.
- The goal is confidence, not coverage. A smaller suite that the team trusts and runs every day beats a large one that everyone skips.
If this was useful, follow me for more practical mobile engineering articles. And I’d like to hear from you: which kind of test has caught the most real bugs in your Flutter apps?










