ADLayoutTest is an example of implementation of property based testing for UI layouts.
The main idea is to generate random view inputs (with SwiftCheck), layout the view, then assert some layout properties are true (with ADAssertLayout).
We can leverage the same idea to make snapshot tests.
You can find an example of use in the Example directory.
Let's say we use the following view in our app, and we want to test it:
import Foundation
import UIKit
struct ExampleViewModel {
let image: UIImage?
let text: String
let subText: String
}
class ExampleView: UIView {
@IBOutlet var imageView: UIImageView!
@IBOutlet var label: UILabel!
@IBOutlet var subLabel: UILabel!
// MARK: - Public
func configure(with viewModel: ExampleViewModel) {
imageView.image = viewModel.image
label.text = viewModel.text
subLabel.text = viewModel.subText
}
}
We want to generate random view models to configure the view.
For this we use SwiftCheck to make ExampleViewModel
conforms to Arbitrary
.
That way we have a random generator of ExampleViewModel
.
import SwiftCheck
extension ExampleViewModel: Arbitrary {
// This is a random generator of `ExampleViewModel`
public static var arbitrary: Gen<ExampleViewModel> {
return Gen<ExampleViewModel>.compose { c in
return ExampleViewModel(
image: c.generate(using: .image(min: 20, max: 100)),
text: c.generate(using: .words),
subText: c.generate(using: .words)
)
}
}
}
The generators .image(min: 20, max: 100)
and .words
are custom generators that create random instances of images or strings.
A basic test case that verify the ExampleView
layout is:
import XCTest
import ADAssertLayout
import ADLayoutTest
class ADLayoutTest_ExampleTests: XCTestCase {
func testExampleView() {
runLayoutTests(named: "ExampleView") { (viewModel: ExampleViewModel) in
// create the view we want to test
let view: ExampleView = ExampleView.fromNib(named: "ExampleViewValid")
// setup the view and pass the random view model
view.backgroundColor = UIColor.white
view.frame = CGRect(x: 0, y: 0, width: 320.0, height: 150.0)
view.configure(with: viewModel)
// layout the view
view.setNeedsLayout()
view.layoutIfNeeded()
// run layout assertions (no view overlap, no ambiguous layout, etc)
do {
try view.ad_runBasicRecursiveTests()
} catch {
return .failure(view, error)
}
return .success(view)
}
}
}
In an error occurs, you can find the reason in the logs.
Test Case '-[ADLayoutTest_ExampleTests.ADLayoutTest_ExampleTests testExampleViewError]' started.
/Users/felginep/Sources/ADLayoutTest/Example/ADLayoutTest_ExampleTests/ADLayoutTest_ExampleTests.swift:83: error: -[ADLayoutTest_ExampleTests.ADLayoutTest_ExampleTests testExampleViewError] : failed - Bottom right corner of <UIImageView: 0x7f8af7c293e0; frame = (20 20; 70 70); clipsToBounds = YES; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <CALayer: 0x60000220cd20>> (superview: <ADLayoutTest_Example.ExampleView: 0x7f8af7c291e0; frame = (0 0; 320 150); autoresize = W+H; layer = <CALayer: 0x60000220ce00>>) overlaps upper left corner of <UILabel: 0x7f8af7c29610; frame = (62 20; 238 20.5); text = 'Erat porta eget venenatis...'; opaque = NO; autoresize = RM+BM; userInteractionEnabled = NO; layer = <_UILabelLayer: 0x600000159770>> (superview: <ADLayoutTest_Example.ExampleView: 0x7f8af7c291e0; frame = (0 0; 320 150); autoresize = W+H; layer = <CALayer: 0x60000220ce00>>).
*** Failed! Proposition: ExampleView Overlap
Falsifiable (after 9 tests):
ExampleViewModel(image: Optional(<UIImage: 0x6000009a4380>, {36, 79}), text: "Erat porta eget venenatis porta", subText: "Elit elit etiam dapibus at erat")
*** Passed 8 tests
.
error: -[ADLayoutTest_ExampleTests.ADLayoutTest_ExampleTests testExampleViewError] : failed - Falsifiable; Replay with 904979560 363827163 and size 8
Note that you can see which input caused the layout error:
ExampleViewModel(
image: Optional(<UIImage: 0x6000009a4380>, {36, 79}),
text: "Erat porta eget venenatis porta",
subText: "Elit elit etiam dapibus at erat"
)
If you want to reproduce the same error for debug purposes, you can use the log message to replay the test using the custom random strategy .replay
.
Falsifiable; Replay with 904979560 363827163 and size 8
The parameter is then:
.replay(
seed1: 904979560,
seed2: 363827163,
size: 8
)
If you go in the test report, you can find a snapshot of the failing view with the layout error highlighted.
You can pass some parameters to the runLayoutTests
function:
snapshotStrategy
: by default, with.failureOnly
when the test fails, a snapshot is taken and is displayed in the test report. If you want to snapshot every view with every input, you can pass the.allTests
parameter.randomStrategy
: by default, withrandom
real random values are generated (meaning you won't have the same view models for two different test executions). If you want to have consistent inputs, use the.consitent
or.replay
parameters.maxTestsCount
: by default 100 tests are run. You can lower the value if needed.
The runLayoutTests
function takes a closure in parameter. This closure returns a ViewAssertionResult
. This result contains two optional values: a view
and an error
.
If present, the view
is used to take a screenshot and display it in the test report.
If an error
is returned, the test fails.
You can use the same technique to create snapshot tests, and assert your view does not change visually during the development.
For this, import a snapshot testing library (in the example we use SnapshotTesting) and use the runLayoutTests
function with a random strategy of .consitent
to generate always the same random values.
func testExampleViewSavedScreenshots() {
runLayoutTests(
named: "ExampleView Saved Screenshots",
randomStrategy: .consistent, // mandatory to have the same screenshots every time
maxTestsCount: 5 // we only want 5 screenshots
) { (viewModel: ExampleViewModel) in
// same code as before
let view: ExampleView = ExampleView.fromNib(named: "ExampleViewValid")
view.backgroundColor = UIColor.white
view.frame = CGRect(x: 0, y: 0, width: 320.0, height: 150.0)
view.configure(with: viewModel)
view.setNeedsLayout()
view.layoutIfNeeded()
// Snapshot the view (depends of the library used)
assertSnapshot(
matching: view,
as: .image
)
// no layout assertions, we just check the generated screenshots
return .success // we don't pass the view here because we don't care
}
}
You can find some useful predefined generators that you can use to create your random inputs.
Gen<Character>
:.upper
,.lower
,.numeric
,.special
,.hexDigits
Gen<String>
:.word
,.words
,.sentences
,email
(word and sentences are built with lorem ipsum content)Gen<UIColor>
:.color
Gen<UIImage>
:.image(min:max:)
Feel free to leverage the Gen
type to create your own generators. You can find all the documentation on the SwiftCheck page.
- iOS 9.0+
- Swift 5.1
CocoaPods is a dependency manager for Cocoa projects. You can install it with the following command:
$ gem install cocoapods
To integrate ADLayoutTest into your Xcode project using CocoaPods, specify it in your Podfile
:
source 'https://github.com/CocoaPods/Specs.git'
platform :ios, '12.0'
use_frameworks!
target '<Your Target Name>' do
pod 'ADLayoutTest'
end
Then, run the following command:
$ po
776C
d install
- If you need help, use Twitter.
- If you'd like to ask a general question, use Twitter.
- If you'd like to apply for a job, visit https://careers.fabernovel.com/.
- If you found a bug, open an issue.
- If you have a feature request, open an issue.
- If you want to contribute, submit a pull request.
ADLayoutTest is owned and maintained by Fabernovel. You can follow us on Twitter at @FabernovelTech.
ADLayoutTest is released under the MIT license. See LICENSE for details.