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

Automatically stop all live mocks at the end of each test case #394

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

dmaclach
Copy link
Contributor

If the user is using XCTest with OCMock, this registers a test observer that takes care
of stopping all live mocks appropriately.

For mocks that are created in +setUp, those will get stopped at the end of the suite.
For mocks that are created in -setUp or in test cases themselves, those will get
stopped at the end of the testcase.

While these mocks are being stopped and testcases/suites are being torndown, messages
sent to mocks are not going to trigger the exception about calling a mock after it has
had stopMocking called on it. This allows objects that may refer to mocks in dealloc
methods to be cleaned up in autoreleasepools or due to stopMocking being called
without the mocks throwing exceptions.

This should greatly simplify cleaning up mocks and remove a lot of potential leakage.
It also makes sure that class mocks that mock class methods will not persist across tests.

@dmaclach
Copy link
Contributor Author

I think we could link this to issue #208

}

+ (void)stopAllTestCaseMocks {
for (OCMockObject *mock in gTestCaseMocksToStop)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be customizable. It's not unusual for clients with high concurrency to clean up mocks after the tests have finished.

@dmaclach
Copy link
Contributor Author

dmaclach commented Mar 26, 2020 via email

@imhuntingwabbits
Copy link
Contributor

The minimum bar is disabled, the nice to have bar would be I can invoke some method that opts instances out of this behavior.

Not all mocks are main thread (as xctest defines it) bound, mocks can (and in some cases are) validated off the main queue out of band with the tests.

You can't force those use cases in to the paradigm at the suite level or the test case level. Not everyone cares about XCTests run loop and some use cases register custom observers of their own to allow the threads to coalesce.

@erikdoe
Copy link
Owner

erikdoe commented Mar 26, 2020

To be honest, I am not in favour of adding such invasive new behaviour to OCMock at this stage, especially not as on-by-default. While I have no data to back this up, I assume that most codebases using OCMock these days are several years old and have grown over time. Making the change suggested in this PR has the potential of causing any number of hard to diagnose issues in existing test stuites, as @imhuntingwabbits mentions.

I have kept #208 open as an enhancement, because I see how having a list of all mocks can help developers diagnose issues with their tests, discovering release cycles or a lingering mock for class methods for example. I can even see how a stopAllMocks method could be used, even though that would be trivial to implement if OCMock maintained a list of mocks, and even though it might be preferrable to fix the underlying issue rather than just stopping the mocks. The cases where you manually need to stop a mock should be rare.

If you were to change this PR to simply maintain a list of mocks, please double-check the coding conventions, e.g. no prefixing of global variables.

Comment on lines 41 to 47
@catch(NSException *e)
{
[OCMockObject removeAMockToStop:self];
[e raise];
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This highlights an implicit extension of the contract between the base mock object class and its subclasses. The subclasses need to know that the base class registers the instance "somewhere", so each and every subclass is now required to do special cleanup in case there's an exception in their own init functionality.

We can do this, even if it's inelegant, for the known subclasses in OCMock itself, but it is a breaking change for codebases that have their own subclasses of OCMockObjects, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it's inelegant, but the only reason that is there is because of the tests for OCMock itself that test exceptions thrown in Mock objects. The standard Cocoa convention is that exceptions signal programmer error and are not intended to be recovered from, and I would argue that goes doubly for throwing from an init method. By default in Objective C, ARC is not exception-safe; I know this is not the case for Objective C++, but I willing to wager that the amount of folks who are throwing in init from an OCMockObject subclass and expect tests to continue cleanly is vanishingly small (present company excluded of course :) ).

FWIW I'm looking at an extremely large codebase with several hundred iOS/macOS targets with over 45K reference to OCMock in on form or another we have only one subclass of an OCMockObject in any form that I can find (and it would not be affected by this change).

Comment on lines 39 to 41
static NSHashTable<OCMockObject *> *gTestCaseMocksToStop;
static NSHashTable<OCMockObject *> *gTestSuiteMocksToStop;
static NSHashTable<OCMockObject *> *gCurrentMocksToStopRecorder;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As general feedback, why have all these as static variables in this file? Wouldn't it be possible to move the state and logic to a separate class? It could have a singleton (shared instance) to manage the state, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do if that's preferred.

gTestCaseMocksToStop = [[NSHashTable weakObjectsHashTable] retain];
gTestSuiteMocksToStop = [[NSHashTable weakObjectsHashTable] retain];
gCurrentMocksToStopRecorder = nil;
gAssertOnCallsAfterStopMocking = YES;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need for this is definitely a smell. I understand the problem, but I'm worried about the complexity that can arise from this additional logic, like "raise when the mock was stopped, but don't raise when it was stopped in a certain way". This'll make diagnosing issues (and answering questions on StackOverflow!) even harder.


+ (void)initialize
{
if([[NSInvocation class] instanceMethodSignatureForSelector:@selector(getArgumentAtIndexAsObject:)] == NULL)
[NSException raise:NSInternalInconsistencyException format:@"** Expected method not present; the method getArgumentAtIndexAsObject: is not implemented by NSInvocation. If you see this exception it is likely that you are using the static library version of OCMock and your project is not configured correctly to load categories from static libraries. Did you forget to add the -ObjC linker flag?"];
if (self == [OCMockObject class]) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just an unrelated clean-up? I'm asking because I know about initialize semantics and thought about adding that if statement earlier myself. I decided against it though, because in the end it doesn't add any value I could think of. If the method isn't there you get the exception. If it's there you're potentially testing it more than once, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally the code in the +load was located here and I didn't want it getting initialized more than once. I usually invert your idea and always put an if in my +initialize because I'm afraid that other developers may not understand the initialize semantics and assume it will be called only once. I'll remove as it adds nothing to this particular CL.


- (void)testSuiteWillStart:(XCTestCase *)testCase {
gAssertOnCallsAfterStopMocking = YES;
gCurrentMocksToStopRecorder = gTestSuiteMocksToStop;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the state and logic would live in its own class, it would be possible to make the code more explicit, e.g. that class could have an explicit flag signalling which collection to use and then have an if statement to put the added mock to the right collection.

Comment on lines +41 to +80
- (void)testSuiteMockWorksHere
{
// Verify that a testSuite Mock made in +setUp doesn't get cleaned up until test suite is done.
// By verifying in two test cases we know this is true (See testSuiteMockWorksAndHere).
XCTAssertEqual([suiteMock intValue], 42);
}

- (void)testSuiteMockWorksAndHere
{
// Verify that a testSuite Mock made in +setUp doesn't get cleaned up until test suite is done.
// By verifying in two test cases we know this is true (See testSuiteMockWorksHere).
XCTAssertEqual([suiteMock intValue], 42);
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're doing with these tests, but you these tests only demonstrate that the cleanup doesn't happen under incorrect circumstances. There's no test to show that the cleanup actually happens. Or, in other words, these tests would pass without the functionality added.

erikdoe added a commit that referenced this pull request May 24, 2020
At this stage I am not sure whether to merge a few more (#419, #404, #398) and release as 3.6.1 or whether to include #421 (and maybe a variation on #394) and then release as 3.7.
@erikdoe erikdoe added this to the OCMock 3.9 (or later) milestone Jun 22, 2020
If the user is using XCTest with OCMock, this registers a test observer that takes care
of stopping all live mocks appropriately.

For mocks that are created in +setUp, those will get stopped at the end of the suite.
For mocks that are created in -setUp or in test cases themselves, those will get
stopped at the end of the testcase.

While these mocks are being stopped and testcases/suites are being torndown, messages
sent to mocks are not going to trigger the exception about calling a mock after it has
had stopMocking called on it. This allows objects that may refer to mocks in dealloc
methods to be cleaned up in autoreleasepools or due to stopMocking being called
without the mocks throwing exceptions.

This should greatly simplify cleaning up mocks and remove a lot of potential leakage.
It also makes sure that class mocks that mock class methods will not persist across tests.
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

Successfully merging this pull request may close these issues.

3 participants