marcglasberg / async_redux

Flutter Package: A Redux version tailored for Flutter, which is easy to learn, to use, to test, and has no boilerplate. Allows for both sync and async reducers.
Other
230 stars 41 forks source link

Connector test not working #71

Closed ghost closed 4 years ago

ghost commented 4 years ago

I didn't find any tests related to Connectors in examples or in the repository, so I started implementing myself.

I expect that after tapping the Login button, LoginUserAction is dispatched. The console prints out the intialization(INI) and end(END) of the LoginUserAction, but the test is running for minutes and times out. My repository is mocked out. What I do wrong?

LoginPageConnector

class LoginPageConnector extends StatelessWidget {
  LoginPageConnector({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, ViewModel>(
        model: ViewModel(),
        builder: (BuildContext context, ViewModel vm) => LoginPage(
              loading: vm.loading,
              message: vm.message,
              onCredentialsProvided: vm.onCredentialsProvided,
            ));
  }
}

class ViewModel extends BaseModel<AppState> {
  ViewModel();

  bool loading;
  String message;
  void Function(String, String) onCredentialsProvided;

  ViewModel.build({@required this.loading,
    this.message,
    @required this.onCredentialsProvided})
      : super(equals: [loading]);

  @override
  BaseModel fromStore() =>
      ViewModel.build(
          loading: state.loading,
          message: state.message,
          onCredentialsProvided: (String email, String password) =>
              dispatchFuture(LoginUserAction(email: email, password: password)));
}

UserRepository

class UserRepository {
  http.Client client = httpClient;

  Future<LoginResponse> login({String email, String password}) async {
    var response = await callLoginAPI(email, password);
    final statusCode = response.statusCode;
    if (statusCode == 200) {
      return LoginResponse(token: response.headers['authorization']);
    } else if (statusCode == 400) {
      return LoginResponse(message: "Payload error, login can't be processed!");
    } else if (statusCode == 401) {
      return LoginResponse(message: "Email or password incorrect!");
    } else if (statusCode == 500) {
      return LoginResponse(
          message: "Server unresponsive, please try again later!");
    } else {
      return LoginResponse(
          message: "Application error, login can't be processed!");
    }
  }

  Future<http.Response> callLoginAPI(String email, String password) async =>
      await client.post('http://192.168.0.137:7071/api/login',
          body: JsonMapper.serialize(LoginRequest(LoginDto(email, password))));
}

LoginUserAction

class LoginUserAction extends ReduxAction<AppState> {
  final String email;
  final String password;
  final UserRepository userRepository = UserRepository();
  final secureStorage = SecureStorageProvider();

  LoginUserAction({this.email, this.password})
      : assert(email != null && password != null);

  @override
  Future<AppState> reduce() async {
    try {
      final loginResponse =
          await userRepository.login(email: email, password: password);
      final token = loginResponse.token;
      if (token != null) {
        secureStorage.persistToken(token);
        addClaimsToStore(token);
        dispatch(NavigateAction.pushNamed('/start'));
      } else {
        return state.copy(message: loginResponse.message);
      }
    } catch (e) {
      print(e);
    }
    return null;
  }

  void addClaimsToStore(String token) async {
    var rawClaims = Jwt.parseJwt(token);
    JsonMapper().useAdapter(JsonMapperAdapter(valueDecorators: {
      typeOf<List<UserWallet>>(): (value) => value.cast<UserWallet>(),
    }));
    final claims = JsonMapper.deserialize<ClaimState>(rawClaims);
    dispatchFuture(AddClaimAction(claims: claims));
  }
}

login_page_CONNECTOR_test

class MockClient extends Mock implements http.Client {}

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  initializeReflectable();
  StoreTester<AppState> createStoreTester() {
    var store = Store<AppState>(initialState: AppState.initialState());
    return StoreTester.from(store);
  }

  MockUserRepository userRepository;
  setUp(() {
    userRepository = MockUserRepository();
  });

  testWidgets('LoginUserAction test', (WidgetTester tester) async {
    await tester.runAsync(() async {
      when(userRepository.login(
              email: anyNamed('email'), password: anyNamed('password')))
          .thenAnswer((_) async => LoginResponse(token: 'token'));
      var storeTester = createStoreTester();

      await tester.pumpWidget(
        StoreProvider<AppState>(
            store: createStoreTester().store,
            child: MaterialApp(home: LoginPageConnector())),
      );

      var emailInputFinder = find.byKey(Key('email-input'));
      await tester.enterText(emailInputFinder, 'user@example.com');

      var passwordInputFinder = find.byKey(Key('password-input'));
      await tester.enterText(passwordInputFinder, '123abc');

      var loginButtonFinder = find.byKey(Key('login-button'));
      await tester.tap(loginButtonFinder);
      await tester.pump();

      var waitUntil = await storeTester.waitUntil(LoginUserAction);

      expect(waitUntil.action, LoginUserAction);
    });
  });
}

Console output

New StoreTester.
New StoreTester.
D:1 R:0 = Action LoginUserAction INI
D:1 R:1 = Action LoginUserAction END
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following StoreExceptionTimeout was thrown while running async test code:
Timeout.

When the exception was thrown, this was the stack:
#0      StoreTester._next.<anonymous closure> (package:async_redux/src/store_tester.dart:577:30)
#13     _Timer._runTimers (dart:isolate-patch/timer_impl.dart:384:19)
#14     _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:418:5)
#15     _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:174:12)
(elided 12 frames from package dart:async, package dart:async-patch, and package stack_trace)
════════════════════════════════════════════════════════════════════════════════════════════════════

Test failed. See exception logs above.
The test description was: LoginUserAction test
marcglasberg commented 4 years ago

I can't really go through all this code. Try to create a very simple example, in a single file with a main method. I must be very simple, at most 30 lines if you want me to have a look at it.

But, at first glance, you are creating the store twice, which is clearly wrong:

New StoreTester.
New StoreTester.

I think instead of this:

store: createStoreTester().store,

It should be this:

store: storeTester.store,
ghost commented 4 years ago

The duplicate StoreTester initialization was the problem, I should have sleep on this to notice that. Anyway I created the single file test, I leave it there for future reference. Definitely not 30 lines.

import 'package:async_redux/async_redux.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';

class MockRepository extends Mock implements Repository {}

class AppState {
  String text;

  AppState({this.text});

  AppState.empty();

  static AppState initialState() => AppState.empty();

  AppState copy({String text}) {
    return AppState(text: text);
  }
}

class Action1 extends ReduxAction<AppState> {
  final Repository repository = Repository();

  @override
  Future<AppState> reduce() async {
    var s = await repository.doSomething();
    return state.copy(text: s);
  }
}

class MyWidget extends StatelessWidget {
  final void Function() fireAction;

  MyWidget({this.fireAction});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Connector test'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        key: Key('my-button'),
        onPressed: fireAction,
        tooltip: 'Fire action',
      ),
    );
  }
}

class MyConnector extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return StoreConnector<AppState, ViewModel>(
        model: ViewModel(),
        builder: (BuildContext context, ViewModel vm) => MyWidget(
              fireAction: vm.fireAction,
            ));
  }
}

class ViewModel extends BaseModel<AppState> {
  ViewModel();

  void Function() fireAction;

  ViewModel.build({@required this.fireAction});

  @override
  BaseModel fromStore() =>
      ViewModel.build(fireAction: () => dispatch(Action1()));
}

class Repository {
  http.Client client = http.Client();

  Future<String> doSomething() async {
    await callAPI();
    return 'Answer from API call';
  }

  Future<http.Response> callAPI() async => await client
      .post('http://192.168.0.137:7071/api/login', body: 'Request body');
}

void main() {
  StoreTester<AppState> createStoreTester() {
    var store = Store<AppState>(initialState: AppState.initialState());
    return StoreTester.from(store);
  }

  MockRepository repository;
  setUp(() {
    repository = MockRepository();
  });

  testWidgets('LoginUserAction test', (WidgetTester tester) async {
      when(repository.doSomething()).thenAnswer((_) async => 'answer');
      var storeTester = createStoreTester();

      await tester.pumpWidget(
        StoreProvider<AppState>(
            store: storeTester.store, child: MaterialApp(home: MyConnector())),
      );

      var buttonFinder = find.byKey(Key('my-button'));
      await tester.tap(buttonFinder);
      await tester.pump();

      var info = await storeTester.wait(Action1);

      expect(info.dispatchCount, 1);
    });
}