---
name: riverpod-notifier-guard-clause-testing
description: |
  Debug silent test failures in Riverpod Notifier tests caused by early-return guard clauses.
  Use when: (1) tests pass but state never changes (stays at initial), (2) type cast errors like
  "type 'FooInitial' is not a subtype of type 'FooLoaded' in type cast", (3) verify() reports
  "no calls at all" for methods you expected to be called, (4) Notifier methods silently no-op
  in tests but work fine in the app. Covers Riverpod Notifier/AsyncNotifier patterns with
  internal state guards, mocktail any() matcher requirements for background operations, and
  reset() patterns for retry/resend verification.
author: Claude Code
version: 1.0.0
date: 2026-02-09
---

# Riverpod Notifier Guard Clause Testing

## Problem

Riverpod Notifier methods often have guard clauses like `if (_field == null) return;` that
silently exit without throwing. In tests, when prerequisite state isn't set up, these methods
return immediately without changing state. This causes confusing failures:

- Type cast errors (`FooInitial is not a subtype of FooLoaded`)
- `verify()` reports zero calls to methods you expected
- Tests appear to "pass" at the wrong assertions

## Context / Trigger Conditions

- **Symptom 1**: `type 'XInitial' is not a subtype of type 'XLoaded' in type cast`
  The state never transitioned because the method returned early.

- **Symptom 2**: `No matching calls (actually, no calls at all)` from mocktail verify.
  The method that should have made the call returned before reaching it.

- **Symptom 3**: Test state remains at `.initial()` after calling a Notifier method.
  The guard clause prevented any state mutation.

- **Framework**: Flutter + Riverpod 3.x + mocktail

## Solution

### 1. Identify Guard Clauses

Search the Notifier source for early returns:

```dart
// Common patterns that cause silent test failures:
Future<void> enterChannel(int channelId) async {
  if (_currentTypeNum == null) return;  // <-- Silent guard
  // ... rest of method never executes
}

Future<void> sendMessage(String content) async {
  if (_currentTypeNum == null || state.activeChannelId == null) return;  // <-- Double guard
  // ...
}
```

### 2. Set Up Prerequisites in Tests

Call prerequisite methods BEFORE the method under test:

```dart
// WRONG - enterChannel returns immediately
await container.read(myProvider.notifier).enterChannel(1);
// State is still FooInitial, cast to FooLoaded throws

// CORRECT - set prerequisite state first
await container.read(myProvider.notifier).loadChannels('bk01');  // Sets _currentTypeNum
await container.read(myProvider.notifier).enterChannel(1);       // Now works
```

### 3. Mock Background Operations with any() Matchers

When the prerequisite method (e.g., `loadChannels`) triggers internal operations like
subscribing to background channels, use `any()` matchers:

```dart
// WRONG - only matches specific channel, breaks when loadChannels subscribes to others
when(() => mockService.subscribeToChannel(1, 'bk01'))
    .thenAnswer((_) => eventController.stream);

// CORRECT - matches all background + foreground subscriptions
when(() => mockService.subscribeToChannel(any(), any()))
    .thenAnswer((_) => eventController.stream);
when(() => mockService.unsubscribeFromChannel(any(), any()))
    .thenAnswer((_) async {});
```

### 4. Use reset() for Retry Verification

When testing retry-after-failure, the initial failed call AND the retry both invoke the same
mock method. Use `reset()` to isolate verification:

```dart
// Initial send fails
await notifier.sendMessage('Test');
// verify sendMessage was called once for the failure

// Reset mock to clear invocation history
reset(mockRepository);

// Re-stub for the retry
when(() => mockRepository.sendMessage(...)).thenAnswer((_) async => confirmed);

// Retry
await notifier.retryFailedMessage(clientId);

// Only counts the retry call
verify(() => mockRepository.sendMessage(...)).called(1);
```

### 5. Know Which Error State Gets Set

Different errors go to different state fields:

```dart
// loadChannels error -> channelsState (ChannelsError), NOT lastError
await notifier.loadChannels('bk01');  // throws
// state.channelsState is ChannelsError
// state.lastError is STILL null

// sendMessage error -> BOTH message.sendFailed AND lastError
await notifier.sendMessage('Test');   // throws
// state.messagesState has message with sendFailed: true
// state.lastError is "Network error"
```

## Verification

After applying fixes:
1. `dart analyze` passes with no errors
2. Tests that were silently passing now actually test behavior
3. State transitions occur: `Initial -> Loading -> Loaded`
4. `verify()` calls report expected invocation counts

## Example

Full working test for a Notifier with guard clauses:

```dart
test('sendMessage creates optimistic message then confirms', () async {
  // 1. Mock ALL dependencies (including background operations)
  final channels = [Channel(id: 1, ...)];
  when(() => mockRepo.getChannels('bk01')).thenAnswer((_) async => channels);
  when(() => mockRepo.getMessages('bk01', 1, limit: any(named: 'limit')))
      .thenAnswer((_) async => (messages: [], hasMore: false, lastReadMessageId: null));
  when(() => mockRepo.getMembers('bk01', 1))
      .thenAnswer((_) async => (members: [Member(userId: 1, name: 'User')], eligibleToAdd: []));
  when(() => mockAbly.subscribeToChannel(any(), any()))
      .thenAnswer((_) => StreamController<Event>.broadcast().stream);
  when(() => mockAbly.getTypingUsers(any(), any()))
      .thenAnswer((_) => StreamController<List<Member>>.broadcast().stream);
  when(() => mockAbly.unsubscribeFromChannel(any(), any())).thenAnswer((_) async {});
  when(() => mockRepo.markAsRead('bk01', 1, lastReadMessageId: any(named: 'lastReadMessageId')))
      .thenAnswer((_) async {});
  when(() => mockPrefs.getPinnedChannelIds(any())).thenReturn(<int>[]);
  when(() => mockRepo.sendMessage('bk01', 1, content: 'Hello', clientMessageId: any(named: 'clientMessageId')))
      .thenAnswer((_) async => Message(id: 100, ...));

  // 2. Set up prerequisite state FIRST
  await container.read(chatProvider.notifier).loadChannels('bk01');
  await container.read(chatProvider.notifier).enterChannel(1);

  // 3. NOW the method under test will actually execute
  await container.read(chatProvider.notifier).sendMessage('Hello');

  // 4. Verify state changed
  final state = container.read(chatProvider).messagesState as ChannelMessagesLoaded;
  expect(state.messages.first.id, 100);
});
```

## Notes

- This pattern applies to ANY Riverpod Notifier with internal state guards, not just chat
- `ChatPreferencesService.getPinnedChannelIds()` is SYNCHRONOUS - use `thenReturn()` not `thenAnswer()`
- Abstract exception classes (e.g., `ChatException`) can't be thrown directly - use concrete subclasses
- Background channel subscriptions happen during `loadChannels`, so `any()` matchers are mandatory
- Debug prints about "Error subscribing to background channel" are harmless in tests - they're caught by try/catch in the source
- Package name `buyer_kiosk_live` (underscores) vs `buyerkiosk_chat` (no underscores) - easy to confuse in imports
