rrousselGit / provider

InheritedWidgets, but simple
https://pub.dev/packages/provider
MIT License
5.11k stars 512 forks source link

Testing widget which are using provider #182

Closed rubiktubik closed 5 years ago

rubiktubik commented 5 years ago

Hi, i'am using the great provider package in my project.

I was about to write a widget test. And there is no documentation how to that with provider.

I have my widget:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<MyViewModel>(
      builder: (BuildContext context, MyViewModel myVm,
          Widget child) { ... }

And i have my definition of providers:

runApp(
    MultiProvider(
      providers: <SingleChildCloneableWidget>[
        ChangeNotifierProvider<MyViewModel>(
            builder: (BuildContext context) => MyViewModel()),
      ],
      child: MyApp(),
    ),
  );

And i have my test:

void main() {
  testWidgets('Should work as expected',
      (WidgetTester tester) async {
    await tester.pumpWidget(MyWidget());
  });
}

How can i test my widget, with using a mock viewmodel?

Regards Michael

rrousselGit commented 5 years ago

Hello!

You need to add the necessary providers in the widget tree passed to pumpWidget:

await tester.pumpWidget(
  Provider.value<MyViewModel>(
    value: someMock,
    child: MyWidget(), 
  ),
) 
rubiktubik commented 5 years ago

Thank you for the quick answer! I'am using mockito to mock my ViewModel but i get

The following ProviderNotFoundError was thrown building Consumer<MyViewModel>(dirty):
Error: Could not find the correct Provider<MyViewModel> above this
Consumer<MyViewModel> Widget

In the pumpWidgetCode i only changed Provider to ChangeNotifierProvider:

class MockMyViewModel extends Mock implements MyViewModel{}
...
Widget createWidgetForTesting({Widget child}) {
    return MaterialApp(
      home: child,
    );
  }
...
final mockVm = MockMyViewModel();
await tester.pumpWidget(ChangeNotifierProvider.value(
      value: mockVm,
      child: createWidgetForTesting(child: MyWidget()),
    ));

The createWidgetForTesting is for using MediaQuery in my widget.

How can i make i work?

rrousselGit commented 5 years ago

You need to manually specify the object type when passed to provider:

DON'T :

Provider.value(
  value: FooMock() 
)

DO:

Provider<Foo>.value(
  value: FooMock(),
) 
rubiktubik commented 5 years ago

Now it works! Thanks!

rrousselGit commented 5 years ago

I'll keep this open as a reminder to document that behavior

zgosalvez commented 4 years ago

@rrousselGit, Thanks too! I did this successfully only after finding this issue. Maybe a complete example can be documented somewhere?

BUT, I can't get my model to notify my listeners/consumers. I'm mocking the model's method that contains notifyListeners(); since it's mocked, I called the mocked model's notifyListeners method manually, however, the Consumer did not rebuild the widget.

Sample snippet in my tests

final auth = MockAuth();
when(auth.check()).thenReturn(false);
when(auth.signIn(email: email, password: password))
    .thenAnswer((_) async => FakeUser());

await tester.pumpWidget(App(
    auth: auth, // App widget builds ChangeNotifierProvider<Auth>.value(value: widget.auth,child: MaterialApp(...
    childAuth: Placeholder(),
    childGuest: OnboardingRoute(),
));

// ...log in steps here in the onboarding route since auth.check is false. A method in my widget calls auth.signIn() which contains notifyListeners() which will not trigger since it's mocked

reset(auth);
when(auth.check()).thenReturn(true);
auth.notifyListeners();

await tester.pump(); // does nothing, but I think it should still trigger from the Consumer listening to Auth/MockAuth

expect(find.byType(Placeholder), findsOneWidget); // ...MaterialApp(child: Consumer<Auth>(builder: (context, auth, child) => auth.check() ? widget.childAuth : widget.childGuest)...
rrousselGit commented 4 years ago

Did you mock notifyListeners itself inadvertently?

zgosalvez commented 4 years ago

I don't think so... I didn't do when(auth.notifyListeners).then...(). Here are a few more snippets for additional context.

// in tests
class MockAuth extends Mock implements Auth {}
// the auth model
class Auth extends ChangeNotifier {
  User _currentUser;

  bool check() {
    return _currentUser != null;
  }

  Future<User> signIn(
      {@required String email, @required String password}) async {
    assert(email != null);
    assert(password != null);

    // ...log in logic here

    notifyListeners();

    return _currentUser;
  }
}
rrousselGit commented 4 years ago

Even if you didn't do when(auth.notifyListener, notifyListeners is still mocked. Not only that, but addListeners/removeListeners are mocked too.

If you want to mock a ChangeNotifier, you can't just do extends Mock implements MyNotifier. You'll need a custom implementation.

zgosalvez commented 4 years ago

That makes it more complicated to test my widget. Is there any easy workaround? I just need Consumer<Auth> to rebuild and call auth.check(). If not, can you give an example of a custom implementation? Did you mean a Fake instead of a Mock? That's a lot of functions to override.

rrousselGit commented 4 years ago

You could extend the true notifier and override check only:

class AuthSpy extends Auth {
  final checkSpy = OnAuthCheck();

  @override
   bool check() => checkSpy();
}

class OnAuthCheck extends Mock {
  bool check();
}
zgosalvez commented 4 years ago

That works. Thank you very much! For those who are interested, here's how I got my specific code to work based on my snippets above.

// on top of the test file
class SpyAuth extends Auth {
  SpyAuth({@required this.checkSpy}) : assert(checkSpy != null);

  final SpyAuthCheck checkSpy;

  @override
  bool check() => checkSpy.check();

  @override
  Future<User> signIn(
      {String email, String password}) async {
    notifyListeners();

    return User();
  }
}

class SpyAuthCheck extends Mock {
  bool check();
}
// in the actual test
final spyAuthCheck = SpyAuthCheck();
final spyAuth = SpyAuth(checkSpy: spyAuthCheck);

when(spyAuthCheck.check()).thenReturn(false);

await tester.pumpWidget(App(
    auth: spyAuth,
    childAuth: Placeholder(),
    childGuest: OnboardingRoute(),
));

// ...log in steps here

reset(spyAuthCheck);
when(spyAuthCheck.check()).thenReturn(true);

// ... tap on log in button here. it will call spyAuth.signIn()

await tester.pump(); // or pumpSettle, depending on your widget hierarchy

expect(find.byType(Placeholder), findsOneWidget); // success!
MaxTenco commented 3 years ago

Hi, I have this issue on console:

The following ProviderNotFoundException was thrown running a test:
Error: Could not find the correct Provider<AuthProvider> above this UserInfoScreen Widget
class MockAuthProvider extends Mock implements AuthProvider {}

void main() {
  Widget makeTestableWidget({Widget child}) {
    return MaterialApp(home: child);
  }

  testWidgets('Test widget', (WidgetTester tester) async {
    final mock = MockAuthProvider();

    when(mock.getFoo()).thenReturn('Foo');

    // ASSEMBLE
    await tester.pumpWidget(
      ChangeNotifierProvider.value(
        value: mock,
        child: makeTestableWidget(
          child: UserInfoScreen(),
        ),
      ),
    );
  });
}
class UserInfoScreen extends StatefulWidget {

  @override
  _UserInfoScreenState createState() => _UserInfoScreenState();
}

class _UserInfoScreenState extends State<UserInfoScreen> with BaseWidgetState {

  AuthProvider _provider;

   @override
  void initState() {
    super.initState();
    _provider = context.read<AuthProvider>();
    final foo = _provider.getFoo();
  }
}
rrousselGit commented 3 years ago

@MaxTenco Make sure to type your provider as ChangeNotifierProvider<AuthProvider> otherwise the type inference uses ChangeNotifierProvider<MockAuthProvider>, which isn't what you want

SzAttila97 commented 3 years ago

Hello!

Im trying to test one of my widgets that calls a function in the provider.

Widget i wanna test:

class CategoriesScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final categoryData = Provider.of<Categories>(context).items;
    return (categoryData == null)
        ? Center(
            child: CircularProgressIndicator(),
          )
        : SingleChildScrollView(
            child: Padding(
              padding: const EdgeInsets.all(25),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                //padding: const EdgeInsets.all(25),
                children: categoryData
                    .map(
                      (categoryData) => CategoryItem(categoryData.id,
                          categoryData.title, categoryData.bgimage),
                    )
                    .toList(),
              ),
            ),
          );
  }
}

The provider with the function:

class Categories with ChangeNotifier {
  List<Category> _items = [
    Category(
      id: 'c1',
      title: 'Akció',
      bgimage: 'https://filmrakat.hu/wp-content/uploads/2018/10/mile22-01.jpg',
    ),
    Category(
      id: 'c2',
      title: 'Animációs',
      bgimage:
          'https://i0.wp.com/nypost.com/wp-content/uploads/sites/2/2019/09/tv-scooby-doo-2b.jpg?quality=80&strip=all&ssl=1',
    ),
    Category(
      id: 'c3',
      title: 'Zenés',
      bgimage:
          'https://www.mafab.hu/static/2018t/273/21/10676_1538422121.6285.jpg',
    ),
    Category(
      id: 'c4',
      title: 'Fantasy',
      bgimage:
          'https://sm.ign.com/ign_hu/screenshot/default/harry-potter_3nyx.jpg',
    ),
    Category(
      id: 'c5',
      title: 'Horror',
      bgimage:
          'https://news.ucdenver.edu/wp-content/uploads/2019/10/Horror-Film-1288x726.jpg',
    ),
    Category(
      id: 'c6',
      title: 'Sci-fi',
      bgimage:
          'https://ectopolis.hu/wp-content/uploads/intelligens-sci-fi-filmek-06.jpg',
    ),
    Category(
      id: 'c7',
      title: 'Vígjáték',
      bgimage:
          'https://www.mafab.hu/static/2019t/168/13/1974_1560855623.0691.jpg',
    ),
    Category(
      id: 'c8',
      title: 'Romantikus',
      bgimage:
          'https://shop.movar-print.hu/wp-content/uploads/2019/03/rozsak-vaszonkep.jpg',
    ),
    Category(
      id: 'c9',
      title: 'Mese',
      bgimage:
          'https://filmrakat.hu/wp-content/uploads/2019/09/clara-tunderi-kaland-film-03-720x450.jpg',
    ),
    Category(
      id: 'c10',
      title: 'Háborús',
      bgimage: 'https://media.port.hu/images/001/106/484.jpg',
    ),
  ];

  List<Category> get items {
    return [..._items];
  }
...

The test:

class MockCategories extends Mock implements Categories {}

Categories categories;

final categoriesResponse = [
  Category(
    id: 'c1',
    title: 'Akció',
    bgimage: 'https://filmrakat.hu/wp-content/uploads/2018/10/mile22-01.jpg',
  ),
  Category(
    id: 'c2',
    title: 'Animációs',
    bgimage:
        'https://i0.wp.com/nypost.com/wp-content/uploads/sites/2/2019/09/tv-scooby-doo-2b.jpg?quality=80&strip=all&ssl=1',
  )
];
...
testWidgets('CategoryScreen', (WidgetTester tester) async {
    final categories = MockCategories();
    await tester.pumpWidget(ChangeNotifierProvider<Categories>.value(
      value: categories,
      child: MaterialApp(home: CategoriesScreen()),
    ));
    await tester.pump();
    expect(find.byType(CircularProgressIndicator), findsNothing);
  });

What im doing wrong? Thanks for the answer in advance!