Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suggestions for non-nullable api #305

Closed
KalilDev opened this issue Dec 21, 2020 · 18 comments
Closed

Suggestions for non-nullable api #305

KalilDev opened this issue Dec 21, 2020 · 18 comments

Comments

@KalilDev
Copy link

While migrating an package, i noticed that the Object props (hashCode, operator==, runtimeType and toString()) were throwing when the dart: libraries used any, because i did not stub them, and stubbing them would be really cumbersome, so i temporarily modified the generated code and after that a lot of tests passed successfully.

For an faster migration on --no-sound-null-safety i enabled returnNullOnMissingStub, then i continued with the migration and after i finished i moved to sound-null-safety, and noticed that a lot of Future<void> returns needed to be stubbed, and this resulted in more verbose tests.

So, my suggestions for the MockSpec and related codegen are:

  • Add either an stubObjectMethods bool parameter or an doNotStub List<Symbol> parameter with an list of methods/accessors to not have mockito code generated. Then store the List that should throw an error in case noSuchMethod is called with them, informing the user that such method/accessor should not be used on when statements or that they should have an concrete implementation in case they were used on a real call.
  • Add either an returnValidNullableFutureOnMissingStub bool parameter or an returnFakeOnMissingStub List<Symbol> which will in the first case return an Future.value() on Future<dynamic>, Future<T?> or Future<void>, or in the second case, a more general approach, it will return the Fake value that was used as the second parameter on noSuchMethod, which is always of the correct type, but not usable.

Also, now that any, anyNamed, captureAny, captureAnyNamed, argThat and captureThat can not be used with non-nullable parameters, maybe an option for having the same functionality would be to add an non-fluent api that allows the user to manually create the argument matchers, for example, when(mockObject, #get).matchParams(none).thenReturn(1) would return 1 in case there was get call with 0 positional parameters, and when(mockObject, #set).matchParams([anyValue, equals(1)]).thenThrow('invalid') which would throw 'invalid' when trying to call #set and the second param is 1.

@srawlins
Copy link
Member

Thanks for the report, @KalilDev , can you say what version of mockito you are using?

@KalilDev
Copy link
Author

I'm using 5.0.0-nullsafety.0 from the c387e34 revision. Thanks for the swift response!

@srawlins
Copy link
Member

@KalilDev at first I did not understand your first request, which is why I asked for your version; it's a total red herring 😆 .

It is really good to get this real-world feedback on features like throw-on-missing-stub. I get the idea that you should provide stubs for methods that code you care about in tests calls, but if code somewhere calls toString(), then you probably don't care a lot about that.

I'm curious though, I am surprised that mocks in tests have these methods called on them a lot, causing you to have to write a bunch of stubs.

Can you give examples about the Future<void> problem? I think I have a sense of what you're getting at, but I'm not 100% sure.

@srawlins
Copy link
Member

maybe an option for having the same functionality would be to add an non-fluent api that allows the user to manually create the argument matchers, for example, when(mockObject, #get).matchParams(none).thenReturn(1)

Yeah this came up when designing the code-gen idea. "What about a whole new API?" I think it might be possible to design a sibling API which is completely separate from the current when(obj.method(any)).thenReturn(x) API, which doesn't have the same ugly non-nullable issues. For the time being, I think we want to focus on the one API, migrating code inside Google to the new API, in particular, and see if we want to support two separate user-facing APIs.

@KalilDev
Copy link
Author

@KalilDev at first I did not understand your first request, which is why I asked for your version; it's a total red herring laughing .

Yeahh, makes sense

I'm curious though, I am surprised that mocks in tests have these methods called on them a lot, causing you to have to write a bunch of stubs.

The problem with the Object properties was mostly because of hashCode and the equality operator. In hive some mock objects were being used as keys in maps, resulting in hashCode calls, and they were also being compared, which used the operator.

Can you give examples about the Future<void> problem? I think I have a sense of what you're getting at, but I'm not 100% sure.
Yes, for example i wanted to test BoxImpl.putAll, which internally calls MockBackend.writeFrames and MockBackend.compact, both of which return Future<void> and are just an implementation detail, but had to be stubbed now, otherwise they would throw, whereas before they didn't have to be stubbed.

Here are some examples of stubs added because of Future<void>:

These are the cases that i could not test with the current mockito null safe api because of the Future<void> problem. Although, i just noticed that DeepCollectionEquality is used to compare the maps and lists, so i will be able to test them, my bad.

Elaborating on my suggestion of returnFakeOnMissingStub, they would be good for testing APIs which perform side effects but whose return value is not used, for example, File.delete, which returns an Future<FileSystemEntity>, RandomAccessFile.lock, which returns an Future<RandomAccessFile>. Just returning an unusable Fake would avoid the need for stubbing them.

Here are some examples of stubs which had to be added when the fake value would suffice:

Reason: The returned future is returned as Future<void>, so the return value is ignored

Here, the value being discarded: https://github.com/hivedb/hive/blob/master/hive/lib/src/io/buffered_file_writer.dart#L28

Reason: The returned futures are just awaited, but never stored or used.

Here: https://github.com/hivedb/hive/blob/master/hive/lib/src/backend/vm/storage_backend_vm.dart#L77 , https://github.com/hivedb/hive/blob/master/hive/lib/src/backend/vm/storage_backend_vm.dart#L90 , https://github.com/hivedb/hive/blob/master/hive/lib/src/backend/vm/storage_backend_vm.dart#L101 , https://github.com/hivedb/hive/blob/master/hive/lib/src/backend/vm/storage_backend_vm.dart#L127 , https://github.com/hivedb/hive/blob/master/hive/lib/src/backend/vm/storage_backend_vm.dart#L129

@KalilDev
Copy link
Author

maybe an option for having the same functionality would be to add an non-fluent api that allows the user to manually create the argument matchers, for example, when(mockObject, #get).matchParams(none).thenReturn(1)

Yeah this came up when designing the code-gen idea. "What about a whole new API?" I think it might be possible to design a sibling API which is completely separate from the current when(obj.method(any)).thenReturn(x) API, which doesn't have the same ugly non-nullable issues. For the time being, I think we want to focus on the one API, migrating code inside Google to the new API, in particular, and see if we want to support two separate user-facing APIs.

Yes, completely understandable

@denniskaselow
Copy link

Something else that may need a different default behavior are setter and void functions.

Calling a void function on a mocked class just throws this:

dart:core                             Object.noSuchMethod
package:mockito/src/mock.dart 149:22  Mock._noSuchMethod
package:mockito/src/mock.dart 143:45  Mock.noSuchMethod
package:foobar/foobar.dart 4:8        MockFoo.doSomething
test\foobar_test.dart 13:9            main.<fn>

NoSuchMethodError: Class 'Object' has no instance method 'doSomething'.
Receiver: Instance of 'Object'
Tried calling: doSomething()

And calling a setter on a mocked class throws this:

dart:core                             Object.noSuchMethod
package:mockito/src/mock.dart 149:22  Mock._noSuchMethod
package:mockito/src/mock.dart 143:45  Mock.noSuchMethod
test\foobar_test.mocks.dart 16:13     MockFoo.content=
test\foobar_test.dart 18:9            main.<fn>

NoSuchMethodError: Object has no content setter

with this being the mocked class:

class Foo {
  String content = 'content';
  void doSomething() {}
}

and this the test:

@GenerateMocks([Foo])
void main() {
  test('call void function', () {
    final foo = MockFoo();

    foo.doSomething();
  });
  test('call a setter', () {
    final foo = MockFoo();

    // doesn't make a difference whether it's a field or an actual setter
    foo.content = '';
  });
}

It works when using returnNullOnMissingStub: true but those are common enough operations that they should work with the default, otherwise there aren't a lot of cases where you can use the default behavior.

@KalilDev
Copy link
Author

KalilDev commented Dec 23, 2020

Dennis, this happens because you did not stub the setter and function in an null-safe library. The mockito codegen automatically opts in throwing on missing stub, and in noSuchMethod, mockito checks if the method is stubbed, and if it is not it calls the default response, which is calling noSuchMethod on an const Object(), which in turn throws NoSuchMethodError.

Using returnNullOnMissingStub only works with unsound null safety, and trying to run the tests with sound null safety will result in TypeErrors, because the returned null is not an valid value of an non nullable type T

@KalilDev
Copy link
Author

KalilDev commented Dec 23, 2020

I thought a long while about this, and with that, i think that:

  • Stubbing Object properties should be opt in only, with an bool flag in MockSpec
  • Trying to call an unstubbed function/accessor whose fake return value is an primitive (bool, num, String or Function) should throw, as the fake return value is valid and usable and may result in unexpected behavior when testing.
  • Trying to call an unstubbed function/accessor should return the Fake value generated by mockito codegen and is expected to be discarded, and when trying to use any method/accessor on this Fake, an error should be thrown instructing the user to stub the method/accessor that instantiated the Fake. This can be done by including the method/accessor name and MockTypeName on the Fake constructor inside the generated Mock classes.

I think i will implement an prototype of this api and check how it feels like.

@denniskaselow
Copy link

denniskaselow commented Dec 23, 2020

Maybe I'm missing something, but how would I go about stubbing a void function and a setter?

when(foo.doSomething()).thenAnswer((_) {});

when(foo.content = '').thenAnswer((_) => '');

works, but is that really what I'm expected to with nullsafety? I'd expect void functions and setters to be pretty much noops unless you do something with thenAnswer or verify and I wouldn't expect them to return anything (neither null nor a Fake).

Concerning stubbing Object properties (if you are referring to hashcode, toString(), == and runtimeType) I think the old behavior would be the correct one, that is, using the function hashcode, toString() and == from the Mock class and runtimeType from the Object class instead of the code generator overriding them in the generated code. They couldn't be stubbed and they never returned null.

@KalilDev
Copy link
Author

I think i will implement an prototype of this api and check how it feels like.

Here, i made the prototype and created a test case to demonstrate the usability: KalilDev@4b8423d

@razamatan
Copy link

re: "primitive" return values: instead of throwing and acting like a Fake for a Mock, can the Mock just return a standard/sane default (0, 0.0, false, ''). if i had to make a strong argument for just one of these, i would strongly want bool to just always return false. bools are important b/c dart compliation/transpilation barfs when evaluating boolean expressions in branches where the mock returns null (not of type bool in a boolean expression).

  1. this is consistent with what mockito's mock spec is (returning a constant value: null for everything but the "primitives") vs mixing fake behavior of throwing on unsepcified test behavior
  2. it's reasonable to assume that if the return value being a valid value has impact, then the dev should be aware of this and properly stub it over

srawlins added a commit that referenced this issue Feb 3, 2021
…Future<void>.

This requires a breaking change, changing Mock.noSuchMethod's optional second parameter to two optional named parameters. Any code which calls noSuchMethod with a second positional parameter, or which overrides Mock.noSuchMethod, will need to change.

Users have found the throw-if-unstubbed setting to be too constrained for setters, other methods that return void, and methods which return a (non-nullable) Future<void>. The method calls should be allowed, as stubbing is probably meaningless.

Fixes #305

PiperOrigin-RevId: 355438518
srawlins added a commit that referenced this issue Feb 3, 2021
…Future<void>.

This requires a breaking change, changing Mock.noSuchMethod's optional second parameter to two optional named parameters. Any code which calls noSuchMethod with a second positional parameter, or which overrides Mock.noSuchMethod, will need to change.

Users have found the throw-if-unstubbed setting to be too constrained for setters, other methods that return void, and methods which return a (non-nullable) Future<void>. The method calls should be allowed, as stubbing is probably meaningless.

Fixes #305

PiperOrigin-RevId: 355438518
@razamatan
Copy link

cee9efd doesn't address primitive bool return values returning nulls in mocks...?

@srawlins
Copy link
Member

srawlins commented Feb 4, 2021

@razamatan That is correct. The motivation that I used for methods that return void (like setters) or Future<void> is that there is sort of nothing to stub. If you have a class:

abstract class C {
  void set a(int value);

   bool isSomething(int x, int y);
}

then a user might want to allow code-under-test to call c.a = 7, given MockC c, without the system throwing saying "you forgot to stub a=!!" The remedy for that situation is quite strange, because stubbing is largely (but not entirely) about return values, as @denniskaselow pointed out. You would tell Mockito to stub a= with a function that ... does nothing.

All that said, I think it does make sense to require a stub for isSomething. If code-under-test calls c.isSomething(1, 2), but you have not declared any stubs that match that call, then who is Mockito to pick a return value (either true or false), which your code-under-test will then use in possibly surprising ways.

An important consequence of that proposed behavior is that it would lead to very-hard-to-diagnose test bugs. Devs unfamiliar with the default return values (like false, 0, '') would really be scratching their heads at the weird return values coming out of their mocks.

@srawlins
Copy link
Member

srawlins commented Feb 4, 2021

@KalilDev I wrote "Fixes #305" in some commit message, but please re-open, or file a new bug, if you don't think I addressed the concerns you raised. I think void and Future<void> were the important ones to hit though...

@razamatan
Copy link

razamatan commented Feb 4, 2021 via email

@srawlins
Copy link
Member

srawlins commented Feb 4, 2021

why isn't the current stub behavior of always returning null not surprising already given your rationale?

We believe it was surprising, which is why throwOnMissingStub was introduced.

and won't break things beyond what silent null return values currently do.

Right, but we think silent null is already bad, and that throwing on missing stubs is better than returning null, or sane values.

I think it is perhaps reasonable to support the old "just return something" way in 'null safe' type system for simple types, as you are suggesting, but not with the default generated mocks. I think we can add another flag to the custom mocks. Right now there is returnNullOnMissingStub. Perhaps we can change this or add "returnSimpleDefaultOnMissingStub`.

@razamatan
Copy link

ok. thanks for the clarifications. having a "simple default" option would be nice, too. :)

generally, what would be the purpose of Mock over Fake in the direction things are headed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants