Skip to content

Testing Best Practices

This guide covers patterns, conventions, and common pitfalls for testing Reacton applications. Following these practices will keep your test suite fast, reliable, and maintainable.

Test Organization

File Structure

Mirror your source tree inside test/:

lib/
  reactons/
    counter_reacton.dart
    auth_reacton.dart
  features/
    checkout/
      checkout_reactons.dart
      checkout_page.dart
test/
  reactons/
    counter_reacton_test.dart
    auth_reacton_test.dart
  features/
    checkout/
      checkout_reactons_test.dart
      checkout_page_test.dart
  integration/
    checkout_flow_test.dart

Naming Conventions

Use descriptive group() and test() names that read like sentences:

dart
group('counterReacton', () {
  test('starts at zero', () { ... });
  test('increments by one', () { ... });
  test('does not go below zero when decrementing', () { ... });
});

group('filteredTodosReacton', () {
  test('returns all todos when filter is "all"', () { ... });
  test('returns only completed todos when filter is "done"', () { ... });
  test('updates when the source todo list changes', () { ... });
});

Do:

  • Start with the reacton or feature name as the group
  • Describe the expected behavior in plain English
  • Include the condition that triggers the behavior

Don't:

  • Use generic names like test('works') or test('test 1')
  • Repeat the group name inside each test name

Grouping by Behavior

dart
group('AuthModule', () {
  group('login', () {
    test('sets token on success', () { ... });
    test('sets error on invalid credentials', () { ... });
    test('clears previous error before retrying', () { ... });
  });

  group('logout', () {
    test('clears token', () { ... });
    test('clears user profile', () { ... });
    test('resets cart state', () { ... });
  });
});

What to Test vs What Not to Test

Test These

CategoryExample
Initial valuesexpect(store.get(counterReacton), 0)
Computed derivationsSetting sources and checking computed output
State machine transitionsValid transitions, invalid transitions (no-op)
Async statesLoading, data, error for asyncReacton
Effects and side effectscreateEffect triggers on the correct changes
Persistence round-tripsWrite, "restart", read back
Edge casesEmpty lists, null values, boundary numbers
Cross-module interactionsAuth state affects cart checkout

Don't Test These

CategoryWhy
Reacton library internalsReacton's own test suite covers this
Flutter framework behaviorTesting Text renders a string is not your job
Third-party API responsesMock the API, test your reacton logic
Exact recomputation countImplementation detail; test the result instead
Private helper functionsTest through the public reacton API

Test Isolation

Every test must start with a clean slate. Create a fresh TestReactonStore in setUp:

dart
void main() {
  late TestReactonStore store;

  setUp(() {
    store = TestReactonStore();
  });

  test('first test', () {
    store.set(counterReacton, 10);
    expect(store.get(counterReacton), 10);
  });

  test('second test does not see state from first', () {
    // This is a fresh store — counter is back to 0
    expect(store.get(counterReacton), 0);
  });
}

Never share a store across tests

Shared mutable state is the most common cause of flaky tests. If test A sets counterReacton to 5 and test B assumes it starts at 0, the suite will break when test order changes.

Isolating Async Reactons

Override async reactons with synchronous values to keep unit tests fast and deterministic:

dart
setUp(() {
  store = TestReactonStore(overrides: [
    AsyncReactonTestOverride.data(userProfileReacton, testUser),
    AsyncReactonTestOverride.data(postsReacton, [testPost1, testPost2]),
  ]);
});

Async Test Patterns

Completing Futures

For async reactons that fetch data, use store.waitFor() to wait for the async operation to complete:

dart
test('loads weather data', () async {
  store.set(cityReacton, 'London');

  await store.waitFor(weatherReacton);

  final weather = store.get(weatherReacton);
  expect(weather.hasData, isTrue);
  expect(weather.valueOrNull?.city, 'London');
});

Testing Loading States

dart
test('shows loading then data', () async {
  final states = <AsyncValue<Weather>>[];
  store.subscribe(weatherReacton, (v) => states.add(v));

  store.set(cityReacton, 'Paris');
  await store.waitFor(weatherReacton);

  expect(states.first.isLoading, isTrue);
  expect(states.last.hasData, isTrue);
});

Fake Timers

For debounce and throttle tests, use fakeAsync to control time:

dart
import 'package:fake_async/fake_async.dart';

test('debouncer waits before emitting', () {
  fakeAsync((async) {
    final store = TestReactonStore();
    final values = <String>[];
    store.subscribe(searchResultsReacton, (v) => values.add(v));

    store.set(searchQueryReacton, 'flu');
    store.set(searchQueryReacton, 'flut');
    store.set(searchQueryReacton, 'flutter');

    // No emission yet — debounce is 300ms
    expect(values, isEmpty);

    async.elapse(Duration(milliseconds: 300));

    // Only the final value triggers the search
    expect(values.length, 1);
  });
});

Testing Error States

dart
test('handles API failure gracefully', () async {
  // Configure mock to throw
  MockWeatherApi.shouldFail = true;

  store.set(cityReacton, 'InvalidCity');
  await store.waitFor(weatherReacton);

  final weather = store.get(weatherReacton);
  expect(weather.hasError, isTrue);
  expect(weather.error, isA<ApiException>());
});

CI/CD Integration Tips

Running Tests

bash
# Run all tests
flutter test

# Run with coverage
flutter test --coverage

# Run a specific test file
flutter test test/reactons/counter_reacton_test.dart

# Run tests matching a pattern
flutter test --name "counterReacton"

GitHub Actions Example

yaml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.24.0'
      - run: flutter pub get
      - run: flutter test --coverage
      - run: flutter pub run reacton_cli analyze
      - uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

Pre-commit Hooks

Add a quick smoke test to your pre-commit workflow:

bash
# In .husky/pre-commit or equivalent
flutter test --tags smoke

Tag your critical tests:

dart
@Tags(['smoke'])
void main() {
  test('app boots without error', () {
    final store = TestReactonStore();
    expect(store.get(appReadyReacton), isTrue);
  });
}

Coverage Strategies

What to Aim For

  • Reacton logic (computed, effects, sagas): 90%+ coverage
  • Widget integration: 70-80% coverage for key user flows
  • Tooling and boilerplate: Don't chase coverage on generated code

Generating Coverage Reports

bash
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Excluding Files from Coverage

Add to your pubspec.yaml or a .lcov ignore file:

yaml
# In analysis_options.yaml
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"

Common Anti-Patterns

1. Testing Through the UI When a Unit Test Suffices

dart
// BAD: Spinning up a full widget tree to test math
testWidgets('total is correct', (tester) async {
  await tester.pumpWidget(
    ReactonScope(child: MaterialApp(home: CartPage())),
  );
  // ...find text, tap buttons, verify...
});

// GOOD: Test the reacton directly
test('total is correct', () {
  final store = TestReactonStore();
  store.set(priceReacton, 10.0);
  store.set(quantityReacton, 3);
  expect(store.get(totalReacton), 30.0);
});

2. Over-Mocking

dart
// BAD: Mocking everything, testing nothing
test('fetches user', () {
  final mockStore = MockReactonStore();
  when(mockStore.get(userReacton)).thenReturn(testUser);
  expect(mockStore.get(userReacton), testUser); // This tests mockito, not your code
});

// GOOD: Use TestReactonStore with real reacton logic
test('fetches user', () async {
  final store = TestReactonStore();
  store.set(authTokenReacton, 'valid-token');
  await store.waitFor(userReacton);
  expect(store.get(userReacton).hasData, isTrue);
});

3. Non-Deterministic Tests

dart
// BAD: Depending on wall-clock time
test('debounce works', () async {
  store.set(queryReacton, 'hello');
  await Future.delayed(Duration(milliseconds: 350));
  expect(store.get(resultsReacton), isNotEmpty);
});

// GOOD: Use fakeAsync
test('debounce works', () {
  fakeAsync((async) {
    store.set(queryReacton, 'hello');
    async.elapse(Duration(milliseconds: 350));
    expect(store.get(resultsReacton), isNotEmpty);
  });
});

4. Giant Test Functions

dart
// BAD: One test doing 15 things
test('the whole app works', () {
  store.set(authTokenReacton, 'token');
  expect(store.get(isLoggedInReacton), true);
  store.set(cartItemsReacton, ['A']);
  expect(store.get(canCheckoutReacton), true);
  // ... 50 more lines ...
});

// GOOD: Focused tests in groups
group('checkout flow', () {
  test('requires login', () { ... });
  test('requires non-empty cart', () { ... });
  test('enables checkout when both conditions met', () { ... });
});

5. Forgetting Cleanup

dart
// BAD: Subscription leak
test('tracks updates', () {
  store.subscribe(counterReacton, (v) => values.add(v));
  // Subscription is never cancelled — may affect next test
});

// GOOD: Always unsubscribe
test('tracks updates', () {
  final unsub = store.subscribe(counterReacton, (v) => values.add(v));
  store.set(counterReacton, 1);
  expect(values, [1]);
  unsub();
});

6. Testing Implementation Instead of Behavior

dart
// BAD: Checking how many times something recomputed
test('computed efficiency', () {
  var count = 0;
  final myComputed = computed((read) {
    count++;
    return read(sourceReacton);
  });
  store.get(myComputed);
  store.get(myComputed);
  expect(count, 1); // Fragile — depends on caching internals
});

// GOOD: Verify the value is correct
test('computed returns expected value', () {
  store.set(sourceReacton, 42);
  expect(store.get(myComputed), 42);
});

Checklist

Before merging a PR, verify:

  • [ ] Every new reacton has at least one test for its initial value
  • [ ] Computed reactons are tested with multiple source combinations
  • [ ] Async reactons are tested for loading, data, and error states
  • [ ] State machines are tested for valid and invalid transitions
  • [ ] Effects and sagas have integration tests for their full flow
  • [ ] No tests depend on execution order
  • [ ] All subscriptions are cleaned up in tests
  • [ ] CI passes with flutter test --coverage

What's Next

Released under the MIT License.