Unit testing in iOS
Recently, the team internally required unit test coverage of 80%+ to ensure code quality. In the process of writing unit tests, there are some things we learned, so let’s summarize.
An overview of the
Unit test is an important module to ensure code quality. Writing Unit tests for modules can reduce bugs in development. At the same time, when refactoring code, if there is a certain granularity of Unit test coverage, can reduce the risk of refactoring, which we all know.
1. Core ideas
- Unit tests must be considered in development, not only as embellishments and additions, but also as important as feature development, including effort measurement.
- Join the daily build, build break will be responsible.
- For some large code changes, run unit test before sending merge Request.
- Don’t add Unit Test after you’ve written the functionality. Do as much TDD as possible.
2. Coverage granularity
In principle, unit tests should cover as many cases as possible, with the finer granularity the better. But in the actual code, not all cases need to be covered, and some coverage is meaningless; Some cases are not covered, so 100% is not possible. Unit Test is about improving code quality, reducing errors, and not forcing a meaningless case to be covered in order to increase coverage.
Unit Test Basic knowledge
1.Get Start
In simple terms, create the target of unit test, create the corresponding test file in this target, and then run, test, check results. But there are a few things to look out for:
-
Each Unit Test class has a -[setUp] method and a -[tearDown] method. You can place actions that require public initialization in -[setUp] and actions that need to be reset or destroyed in -[tearDown]. Each execution of the test method creates a new instance and calls both methods. If there are more than one test method in a class, both methods will be called multiple times.
-
When executing unit test, you first need to select target as the target of Unit test; Second, set the run option to “Test” as shown in the following figure. Do not select “Test” will run, but sometimes some errors, the results are not accurate.
-
If a method does not run in a “little diamond”, see the following figure. Check to see if your file is added to the test target (or if you cannot read a local file).
2. Overview of Test Assertions
In some test methods, you use assert provided by the XCTest Framework, which falls into the following categories:
- Boolean AssertionsIs used to determine whether the result is true or false. For example,
XCTAssertTure
,XCTAssertFalse
. - Nil and Non-nil AssertionsTo determine whether the result is nil. For example,
XCTAssertNil
,XCTAssertNotNil
. - Equality and Inequality Assertions, to determine whether two classes or values are equal. For example,
XCTAssertEqual
,XCTAssertEqualObjects
,XCTAssertEqualWithAccuracy
. - Comparable Value Assertions, mainly used for size comparisons (>,<,>=,<=). For example,
XCTAssertGreaterThan
,XCTAssertCreaterThanOrEqual
. - Error AssertionsIs mainly used for exception testing to determine whether an expression will throw an exception and the specific exception information. For example,
XCTAssertThrows
,XCTAssertThrowsSpecific
. - Failing UnconditionallyTo actively trigger a failure, or to flag a failure. For example,
XCTAssertFail
. - Asynchronous Tests and ExpectationsIt’s not assert, it’s a few exceptions, async, KVO, Notification, etc. For example,
XCTestExpectation
,XCTKVOExpectation
,XCTNSNotificationExpectation
.
The first ones are familiar, but let’s talk about the last one, and there are a couple of norms that are unfamiliar to us, mainly for the UI Test. Asynchronous Tests and Expectations have the following classes:
- XCTKVOException, used when listening for a property change (KVO) of an object. Such as:
- (void)testMethod {
UIView *view = [UIView new]; // Listen to the object
XCTKVOExpectation *kvoExceptation = [[XCTKVOExpectation alloc] initWithKeyPath:@"tag" object:view];
XCTWaiterResult result = [XCTWaiter waitForExpectations:@[kvoExceptation] timeout:3];
XCTAssertTrue(result == XCTWaiterResultCompleted);
}
Copy the code
- XCTNSNotificationExpectation/XCTDarwinNSNotificationExpectation, test send notification use (NSNotification and Darwin NSNotification). Such as:
- (void)testMethod {
XCTNSNotificationExpectation *notificationExpectation = [[XCTNSNotificationExpectation alloc] initWithName:@"kNotificationName"];
XCTWaiterResult result = [XCTWaiter waitForExpectations:@[notificationExpectation] timeout:3];
XCTAssertTrue(result == XCTWaiterResultCompleted);
}
Copy the code
- The use of XCTNSPredicateExpectation, test the predicate expression, such as:
NSNumber *str = @123;
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"SELF BETWEEN {100, 200}"];
XCTNSPredicateExpectation *predicateExpectation2 = [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:str];
XCTWaiterResult result = [XCTWaiter waitForExpectations:@[predicateExpectation2] timeout:3];
XCTAssertTrue(result == XCTWaiterResultCompleted);
Copy the code
3. Run the Unit test shortcut key
There are a number of ways to run the Unit Test, including hitting the Run button; You can choose to run all or a single class or a single case in the Test Navigator. You can also click run in the source code. Of course, you can also use shortcuts. Here are some shortcuts:
- Run all unit tests: Command + U
- Build unit test only: Shift + Command + U
- Run, build unit test: Control + Command + U
- Run only one case (the current case where the cursor is): Control + Option + Command + U
4. Breakpoint debugging
Add a ‘Test Failure Breakpoint’ Breakpoint in the Breakpoint Navigator to stop when a Failure occurs for easy debugging.
5.Code Coverage
Through code coverage, you can check the coverage of unit test of each module, and even the coverage of each case in each class. Code Coverage can be turned on from the Scheme menu.
The check result is as follows:
In the code, you can check whether the method is covered. In the figure below, red represents not covered, green represents covered, and the number in green represents the number of times the code was hit during the test. You can turn on and off Coverage information by using Editor -> Hide/Show Code Coverage.
6. Run the unit test command
This is of no practical use at the moment, but is briefly mentioned here. You can run the unit test command in the following format:
xcodebuild test [-workspace <your_workspace_name>]
[-project <your_project_name>]
-scheme <your_scheme_name>
-destination <destination-specifier>
[-only-testing:<test-identifier>]
[-skip-testing:<test-identifier>]
Copy the code
eg: xcodebuild test -workspace MTBusinesskitDev.xcworkspace -scheme MTBusinessKitTests -destination 'platform=iOS Simulator,name=iPhone 7'
For more information check out How do I run unit tests from the Command line? .
Practics
1. Test the path
-
Consider both the correct path and the incorrect path, deliberately creating false cases. In MTBAdLoadInfoTest, for example, several sets of false data are deliberately created for testing.
-
Testing multipathing. Many classes may have multiple cases that need to be covered completely. MTBMeituBusinessAdRequest, for example, according to the load from the cache or load from the web, phase1 or phase2, the cache is valid, and so on and so forth, can have a variety of case combination, set to be thoughtful, completely covered.
- Consider the boundary case. For example, in
MTBBatchReportDataManager
Class, tests “More than 15 days” (-[checkDateIsPast:]
Method). Cases that need to be tested for less than 15 days and those that need to be tested for more than 15 days need to be tested for exactly 15 days.
2. Test some private methods and use private properties
During testing, you may need to call or test some private methods, and you may need to use some private properties. You can create a new private category file and put some private methods and properties into it. Then import this file into test case.m.
In principle this private category file is only in the test target and is only imported by test case.m.
For example, when testing the MTBBusinessAdPreload class, add the MTBBusinessAdPreload+ private. h file, which reads as follows:
@interface MTBPreloadModel(a)
+ (instancetype)preloadModelWithInfo:(NSDictionary *)info parsingError:(NSString *__autoreleasing *)errorStr;
@end
@interface MTBBusinessAdPreload (Pirvate)
@property (nonatomic.strong) NSMutableArray <NSDictionary *> *resourceToDownloadDic;
// preload related methods
- (MTBPosition *)createPositionWithAdIndexInfo:(MTBAdIndexInfo *)adIndexInfo;
- (void)replaceRoundAndIdeaIDWithPreloadData:(MTBPreloadModel *)preloadModel;
- (NSDictionary <NSString *, NSArray *> *)replaceCreativesWithPreloadData:(MTBPreloadModel *)preloadModel;
// download related methods
- (void)downloadMaterials:(NSDictionary *)resourcesToDownload;
// Cache operation related methods
- (void)cacheResourceToDownload:(NSDictionary *)dic;
- (NSMutableDictionary *)cachedResourceToDownload;
- (void)removeResourceFromCache:(NSString *)creativeId;
@end
Copy the code
3. Asynchronous method testing
The system provides a dedicated API for testing asynchronous logic. All apis that deal with callback mechanisms such as notification, observer, listener, etc., can write cases, which are supported by different platforms. For example in MTBAnalyticsReportDataTest asynchronous test:
- (void)testReportAdInfo {
...
XCTestExpectation *exception1 = [self expectationWithDescription:@"Report Data"];
[[MTBAnalyticsReportData shared] logEventWithReportInfo:allParams completion:^(NSError *error, BOOL success) {
XCTAssertNil(error);
XCTAssertTrue(success);
[exception1 fulfill];
}];
XCTestExpectation *exception2 = [self expectationWithDescription:@"Report nil"];
[[MTBAnalyticsWebService shared] reportAdInfo:nil completion:^(NSError *error, BOOL success) {
XCTAssertTrue(error.code == 1010);
XCTAssertFalse(success);
[exception2 fulfill];
}];
[self waitForExpectationsWithTimeout:8.0 handler:nil];
}
Copy the code
Some asynchronous tests may need to verify that the thread is safe. Such as:
MTBReqeust *request = [MTBReqeust new];
[request loadData:^(id data){
XCTAssertTrue([NSThread mainThread]);
}
Copy the code
In addition, not only should you write cases that call the asynchronous API independently, you can also consider concurrent logic that calls the API multiple times on the same object.
For asynchronous apis, there is always a built-in callback logic for repeated calls. For asynchronous apis, the corresponding logic for calling the same object again after calling the API method does not callback (ie. Concurrency models), would be one of the following
- Each call to the API will definitely trigger a callback in the future
- New calls are ignored, and existing calls continue. (In the previous example, a second call to the Request method immediately returns synchronously
false
), old calls trigger callback at some point in the future - The old call will automatically cancel immediately/the old call will trigger a failure callback immediately, and the new call will execute normally and trigger a callback at some point in the future
- The old callback is taken over by the new callback. The old callback is no longer fired, and another callback is called when the new call is complete
- The API does not allow the first call to trigger a new call without a callback. If this happens, an exception will be thrown immediately
If you have never considered this problem before writing a case, there are hidden dangers in using this API. No matter which concurrent call strategy our API uses, you can write a case to rigorously verify this problem, which will not be covered here.
4. Simulate operations
When writing cases, there are times when we can’t create a realistic scenario and we need to simulate it.
(1) Analog system notification
The video needs to be paused when home is sent out. We can do it in two ways
- 1 is to simulate the system to send notification. This method is simple, but may affect some other logic
- 2 Reconstruction of the video class interface, the
func pause()
Method extended intofunc pause(cause:)
Among themcause
The parameter indicates the cause of pause, for example, including “active click”, “page disappears”, “enter background”, etc., and then place part of the original inpause
The external logic moves into the method and treats the different arguments passed in differently. So in the test case, you just need to use different onescause
Call the pause method.
(2) Simulate the passage of time
In the open screen of some logic, home out and back, need to judge according to the last display time, whether it is necessary to display open screen; In the batch reporting logic, you need to create an expiration time when testing whether data is expired. For example, in MTBSplashAdManagerTest, subtract 200s from the present and pass it in, so that the time received by the program is a “past” time:
- (void)testAppWillEnterForeground4 {
[MTBSplashStatus setAppeared:NO];
self.manager.splashShownCountInWarmStart = 0;
self.manager.isIntervalLargerThanSetting = YES;
self.manager.hasPendingDisplayTask = NO;
// Go back 200s and enter the APP again after 120s, displaying the open screen.
NSTimeInterval interval = [[NSDate date] timeIntervalSince1970] - 200;
self.manager.lastLeaveDate = [NSDate dateWithTimeIntervalSince1970:interval];
[self.manager appWillEnterForeground];
XCTAssertFalse(self.manager.isColdStart);
}
Copy the code
5. Network related tests
In principle, local unit tests do not depend on network conditions. Because of running unit tests while packing, the baler network may go down, or the server may go down. This is a unit test. If you don’t get it, you’ll fail. Therefore, some network requests need to be simulated.
When testing some interface parsing, you need some JSON data, which is normally requested from the server. During testing, local data can be read directly. For example, when testing the preload, Load, and setting interfaces, data is read locally.
In addition, you can inherit and rewrite some methods in the MTBSimpleHttpFetcher class to specify the data to return. It is essentially the same as reading data locally, for example:
@interface MTBSimpleHttpFetchreMock:MTBSimpleHttpFetcher @end @implementation MTBSimpleHttpFetchreMock - (id<MTBCancellable>)loadResource:(MTBRemoteResource *)resource reportQueue:(dispatch_queue_t)reportQueue completion:(MTBRequestCompletion)completion { ... dispatch_async(reportQueue, ^{ id data = [NSObject new]; // You can customize data, error, etc. completion(data,data,nil); }... } @endCopy the code
Then by creating MTBSimpleHttpFetchreMock class, call – [loadResource: reprotQueue: completion:] method to simulate network pull.
conclusion
1. Unit test needs to be isolated from the APP itself. Unit test execution should not affect the APP logic
- Try not to ask whether the iOS Unit test needs host to be added to app. But at present, because the bundle info cannot be obtained, only host is required, which should be removed later.
- The process executed by Test Case is not the same as the Main App, but it is the same for global UI objects provided by some systems, such as UIApplication and view hierarchy, so avoid test Case operating on the UI of the Main App. If executing a case is bound to work, consider masking it in other ways.
- By adding a test environment variable to Scheme, you can determine whether a method is called in the main App process or the process of the Test Case code.
- Focus on data cleanup when test case exits
XCTestCase
In the- func teardown()
- static func teardown()
- WaitForExpectations (timeout:handler:) Second parameter
- If complex persistent storage is not easily separated from the main app data, or if it is not possible to clean up the test data separately, you should specify a different path to the data store source and abstract that part of the data source as described above. Let the test case respecify another data source for the test.
2. The enlightenment of Unit Test for development
- Modules with high cohesion and low coupling, a class stem is one thing clear
- Multi-purpose composition (as opposed to inheritance)
- Do not abuse singletons, which make it difficult to write separate test cases for this class