Video #38: Protocol-Oriented Library Design: Part 2
Episode: Video #38 Date: Nov 19, 2018 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep38-protocol-oriented-library-design-part-2

Description
With our library fully generalized using protocols, we show off the flexibility of our abstraction by adding new conformances and functionality. In fleshing out our library we find out why protocols may not be the right tool for the job.
Video
Cloudflare Stream video ID: 038ff3a5c7bc945a1c611d5cf9c918f4 Local file: video_38_protocol-oriented-library-design-part-2.mp4 *(download with --video 38)*
References
- Discussions
- Protocol-Oriented Programming in Swift
- uber/ios-snapshot-test-case
- Snapshot Testing in Swift
- Protocol Witnesses: App Builders 2019
- 2019 App Builders
- 0038-protocol-oriented-library-design-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
OK! So everything compiles and tests run and pass just as before! Unfortunately it was pretty complicated. We really butted heads with some seriously complicated protocol features, like Self , non-final classes, and required initializers. But at least now the Snapshottable protocol is completely decoupled from taking image snapshots. In fact, it’s more generic than any snapshot testing library out there. Can we do anything interesting with that? Making strings diffable and snapshottable
— 0:53
One perfectly reasonable format to diff is text, like all of the various string representations of data in our app, including CustomStringConvertible , CustomDebugStringConvertible , and formats like JSON and XML. What does it take to start writing snapshot tests against strings? We can extend String to our new protocols.
— 1:35
Let’s start with Diffable . Xcode can stub things out for us. extension String: Diffable { static func diff(old: String, new: String) -> [XCTAttachment] { <#code#> } static func from(data: Data) -> String { <#code#> } var to: Data { <#code#> } }
— 1:52
We can use a line-diffing helper we have to diff our strings, and we can make calls to some common decoding and encoding logic for the rest! extension String: Diffable { static func diff(old: String, new: String) -> [XCTAttachment] { guard let difference = Diff.lines(old, new) else { return [] } return [XCTAttachment(string: difference)] } static func from(data: Data) -> String { return String(decoding: data, as: UTF8.self) } var to: Data { return Data(self.utf8) } }
— 3:03
What about Snapshottable ? Let’s conform String and let Xcode stub things out. extension String: Snapshottable { typealias Snapshot = <#type#> }
— 3:19
Well the first thing we need to do is give Swift a hint of what the associated Snapshot type is. In this case it’s the same type, String . extension String: Snapshottable { typealias Snapshot = String }
— 3:28
When we run Xcode’s fix-it, it inserts the remaining stub. extension String: Snapshottable { var snapshot: String { <#code#> } typealias Snapshot = String } And the implementation is simple enough, we just return self ! We can even get rid of the type alias since the compiler can now figure it out. extension String: Snapshottable { var snapshot: String { self } }
— 3:50
Everything compiles, so now we should be able to do some text-based snapshot testing! Writing a text-based snapshot test
— 3:55
Let’s add a sample test to our test case. It’ll test a Point-Free greeting string. func testString() { assertSnapshot( matching: """ Welcome to Point-Free! --------------------------------------------- A new Swift video series exploring functional programming and more. """ ) }
— 4:15
When we run it… failed - Recorded: … “…/ Snapshots /SnapshotTestingTests/testString.png” It recorded! It’s…not quite right…we recorded a “png”, so it looks like we missed another protocol requirement. A missing protocol requirement
— 4:36
When we take a look at assertSnapshot , lo and behold we have "png" hard-coded inline. let referenceUrl = snapshotUrl(file: file, function: function) .appendingPathExtension("png")
— 4:45
When we take a look at assertSnapshot , lo and behold we have "png" hard-coded inline. Let’s add this requirement to Snapshottable protocol Snapshottable { associatedtype Snapshot: Diffable static var pathExtension: String { get } var snapshot: Snapshot { get } }
— 5:02
We get a whole bunch of errors for each Snapshottable type. We can add "txt" as a path extension for String : extension String: Snapshottable { static let pathExtension = "txt" var snapshot: String { return self } } And we can add "png" as a path extension for UIImage , CALayer , UIView , and UIViewController : extension UIImage: Snapshottable { static let pathExtension = "png" … } extension CALayer: Snapshottable { static let pathExtension = "png" … } extension UIView: Snapshottable { static let pathExtension = "png" … } extension UIViewController: Snapshottable { static let pathExtension = "png" … }
— 5:35
Everything builds! We just need to update assertSnapshot to make the appropriate call. let referenceUrl = snapshotUrl(file: file, function: function) .appendingPathExtension(S.pathExtension)
— 5:48
Everything builds, but we made our library a little bit more cumbersome to use in the process. Our UIImage , CALayer , UIView , and UIViewController conformances were all very redundant. We repeatedly had to specify the path extension even though "png" makes sense for every UIImage -based conformance. Having to do this extra work for every conformance seems unnecessary.
— 6:23
Lucky for us, protocols have a great feature that allows us to avoid that extra work. We can extend them with default implementations. extension Snapshottable where Snapshot == UIImage { static var pathExtension: String { return "png" } }
— 6:45
And now we can delete all of the individual implementations we defined earlier.
— 6:56
When we run our tests again we get another recording failure, but with a more suitable path extension. failed - Recorded: … “…/ Snapshots /SnapshotTestingTests/testString.txt” We can run our tests again and they succeed!
— 7:27
When the value changes, for example, let’s drop the “new” since we’ve been around for quite awhile now: func testString() { assertSnapshot( matching: """ Welcome to Point-Free! ----------------------------------------- A Swift video series exploring functional programming and more. """ ) }
— 7:40
We get a failure! failed - Snapshot didn’t match reference Ergonomic error messages
— 7:43
We can inspect the failure by hopping on over to the report navigator and expanding the test’s attachments, where we can quick look at the difference. @@ −1,4 +1,4 @@ Welcome to Point-Free! −--------------------------------------------- −A new Swift video series exploring functional +----------------------------------------- +A Swift video series exploring functional programming and more.
— 8:06
Xcode’s attachments are super cool, but not super convenient, having to switch over to a new tab and expand stuff every time. Because we’re working with text now, maybe we can hook into the failure message and display the diff more conveniently inline. We just need to modify our Diffable protocol again.
— 8:48
Instead of returning just an array of attachments, we can return a structure describing the failure: a tuple containing a custom error message an array of attachments. protocol Diffable { static func diff( old: Self, new: Self ) -> (String, [XCTAttachment])? static func from(data: Data) -> Self var to: Data { get } }
— 9:11
If the diff succeeds, it can return nil instead of a failure. We can now update our Diffable types. extension String: Diffable { static func diff( old: String, new: String ) -> (String, [XCTAttachment])? { guard let difference = Diff.lines(old, new) else { return nil } return ( "Diff: …\n\(difference)", [XCTAttachment(string: difference)] ) } For String we can return a failure message that includes the line diff.
— 9:44
And for UIImage we can return a failure message that includes some additional size information. static func diff( old: UIImage, new: UIImage ) -> (String, [XCTAttachment])? { guard let difference = Diff.images(old, new) else { return nil } return ( "Expected old@\(old.size) to match new@\(new.size)", [old, new, difference].map(XCTAttachment.init) ) }
— 10:27
We now need to make some minimal changes to assertSnapshot to work with the changes. guard let (failure, attachments) = S.Snapshot.diff(reference, snapshot) else { return } XCTFail(failure, file: file, line: line)
— 10:55
And everything builds! Let’s check out those updated failure messages. What’s the point?
— 11:30
Alright, we’ve just refactored a snapshot library from working with only UIView s to a library that abstracts over the shape of “snapshottable” things using protocols, and we now have a library that supports both image and text-based snapshots of a variety of different types! We have something that is already more flexible than any other single snapshot testing library out there, so shouldn’t we be happy and call it a day!? What’s been the point of all this witness stuff!?
— 12:09
It’s true, protocols have taken us pretty far, and we have a super useful library right here. In fact, it forms the basis of a library that we designed over a year ago in the protocol-oriented, Swifty way. It served us well for awhile, and it was hard to think that there was anything wrong with it. But as we’ve seen over the past four episodes and even during this one, protocols have their limits and their quirks. Today we encountered some tricky business around capital Self requirements, naming collisions, and the complexity of constrained associated types and protocol extensions with default conformances. We carried on anyway, but I think we need to address a problem which has been looming over our design from the start: we can’t conform a type to a protocol more than once.
— 12:55
So why would we want to do that? Is there even more than one practical conformance for UIView ? Well UIView has a method, recursiveDescription , that is often used to troubleshoot view hierarchies from the debugger. It returns a pretty-printed string of a view and its children and is a perfect candidate for as a text-based snapshot. Of course, we can’t have multiple conformances on UIView , so we’ll have to comment out our image-based conformance.
— 13:46
Let’s add a string-based snapshot to UIView . Unfortunately, we have no choice but to trample over our existing image-based conformance. extension UIView: Snapshottable { // var snapshot: UIImage { // return self.layer.snapshot // } var snapshot: String { } }
— 14:19
We can implement this by calling to the private recursiveDescription method on views. extension UIView: Snapshottable { // var snapshot: UIImage { // return self.layer.snapshot // } var snapshot: String { return self.perform( Selector(("recursiveDescription")) ) .takeUnretainedValue() as! String } }
— 15:01
We’ve now made UIView snapshottable by capturing its recursive description as a string, but our UIViewController conformance no longer works because it relies on the earlier, image-based conformance, so we need to update it as well. extension UIViewController: Snapshottable { // var snapshot: UIImage { // return self.view.snapshot // } var snapshot: String { return self.view.snapshot } }
— 15:27
We’ve satisfied the snapshot requirement, but we haven’t satisfied the conformance. We’re no longer dealing with UIImage s, so we no longer have the default pathExtension conformance of "png" . We can, however, add a default conformance for String with the path extension "txt" . extension Snapshottable where Snapshot == String { static var pathExtension: String { return "txt" } }
— 15:56
Now when we run our test, we get an all-new snapshot. failed - Recorded: “…/ Snapshots /SnapshotTestingTests/testEpisodesView.txt”
— 16:12
And when we re-run it, we get a failure? failed - Diff: … @@ -1,1 +1,1 @@ −<UITableView: 0x7fc3db822400; frame = (0 0; 375 812); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fc3d9c9bea0>; layer = <CALayer: 0x7fc3d9b5dcf0>; contentOffset: {0, 0}, contentSize: {0, 0}, adjustedContentInset: {0, 0, 0, 0}> +<UITableView: 0x7fed37814c00; frame = (0 0; 375 812); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x7fed364208c0>; layer = <CALayer: 0x7fed38439f40>; contentOffset: {0, 0}, contentSize: {0, 0}, adjustedContentInset: {0, 0, 0, 0}> It looks like recursiveDescription embeds memory addresses in the string, and these are always going to change, so let’s adjust our snapshot implementation to strip them out. var snapshot: String { return ( self.perform(Selector(("recursiveDescription"))) .takeUnretainedValue() as! String ) .replacingOccurrences( of: ":?\\s*0x[\\da-f]+(\\s*)", with: "$1", options: .regularExpression ) }
— 16:44
Now we can re-record our view controller snapshot, and repeat test runs pass!
— 16:58
Let’s take a look at that snapshot. <UITableView; frame = (0 0; 375 812); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray>; layer = <CALayer>; contentOffset: {0, 0}, contentSize: {0, 0}, adjustedContentInset: {0, 0, 0, 0}> It’s a single, long line without the memory addresses from before. We can see it has a bunch of attributes, including a content size of zero. It seems that it still has some work to do, so let’s force it to do some layout before taking our snapshot. var snapshot: String { self.setNeedsLayout() self.layoutIfNeeded() return ( self.perform(Selector(("recursiveDescription"))) .takeUnretainedValue() as! String ) .replacingOccurrences( of: ":?\\s*0x[\\da-f]+(\\s*)", with: "$1", options: .regularExpression ) }
— 18:19
We can re-record our snapshot and this time it includes a lot more info. <UITableView; frame = (0 0; 375 812); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray>; layer = <CALayer>; contentOffset: {0, 0}; contentSize: {375, 2040.3333435058594}; adjustedContentInset: {0, 0, 0, 0}> | <PointFreeFramework.EpisodeCell; baseClass = UITableViewCell; frame = (0 748.333; 375 492); autoresize = W; layer = <CALayer>> | | <UITableViewCellContentView; frame = (0 0; 375 491.667); gestureRecognizers = <NSArray>; layer = <CALayer>> | | | <UIStackView; frame = (0 0; 375 491.667); layer = <CATransformLayer>> | | | | <UIImageView; frame = (0 0; 375 211); userInteractionEnabled = NO; layer = <CALayer>> | | | | <UIStackView; frame = (0 211; 375 280.667); layer = <CATransformLayer>> | | | | | <UILabel; frame = (24 24; 134.667 14.3333); text = '#2 • Monday Feb 5, 2018'; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | | | <UILabel; frame = (24 50.3333; 114 26.3333); text = 'Side Effects'; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | | | <UILabel; frame = (24 88.6667; 321.667 118); text = 'Side effects: can’t live ...'; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | | | <UIButton; frame = (24 218.667; 123 30); opaque = NO; layer = <CALayer>> | | | | | | <UIButtonLabel; frame = (0 6; 122.667 18); text = 'Watch episode →'; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | <UIView; frame = (238.667 12; 112.333 22.3333); clipsToBounds = YES; hidden = YES; layer = <CALayer>> | | | | <UILabel; frame = (8 4; 96.3333 14.3333); text = 'Subscriber Only'; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | <_UITableViewCellSeparatorView; frame = (15 491.667; 360 0.333333); layer = <CALayer>> …
— 18:30
There is a lot being captured here. We have all the properties, frames, and the entire tree hierarchy of our views and how deep they go! We now have another conformance for UIView s and UIViewController s that’s completely valid. It’s no better or worse than image-based snapshots. Each have pros and cons and each does things their own way. But because we’re using Swift protocols, we have no choice but to choose one and miss out on the other!
— 19:04
And it’d be a big bummer to miss out on having both strategies! The text-based view hierarchy dump captures a lot of information that the image-based snapshot did not, including off-screen cells and hidden state.
— 19:21
Screen shots also typically have to be re-recorded during operating system upgrades, like from iOS 11 to 12, because UI and text can render slightly differently in each release. And while recursiveDescription output could change in the future, it seems to be much more stable, and we could always write our own version of recursiveDescription that remains constant.
— 20:02
Here we have a brand new way of snapshotting views that’s probably completely new to the iOS community, and it’s a way of snapshotting views that the community should be taking advantage of! It captures state and catches changes that a screen shot test may not.
— 20:21
For example, the episode table view cell contains a “subscriber-only” label that is hidden for free episodes. It turns out that the cell that’s visible in our screen shot test is a free episode, so we’re not actually capturing this state in a test. If we refactored our cell and somehow forgot to add this label to the view hierarchy, our screen shot test would still pass. Out text-based snapshot of the view hierarchy, on the other hand: failed - Diff: … @@ -8,10 + 8,8 @@ | | | | | <UILabel; frame = (24 50.3333; 114 26.3333); text = ‘Side Effects’; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | | | <UILabel; frame = (24 88.6667; 321.667 118); text = ‘Side effects: can’t live …’; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | | | | <UIButton; frame = (24 218.667; 123 30); opaque = NO; layer = > | | | | | | <UIButtonLabel; frame = (0 6; 122.667 18); text = ‘Watch episode →’; opaque = NO; userInteractionEnabled = NO; layer = <_UILabelLayer>> − | | | <UIView; frame = (238.667 12; 112.333 22.3333); clipsToBounds = YES; hidden = YES; layer = > − | | | | <UILabel; frame = (8 4; 96.3333 14.3333); text = ‘Subscriber Only’; userInteractionEnabled = NO; layer = <_UILabelLayer>> | | <_UITableViewCellSeparatorView; frame = (15 491.667; 360 0.333333); layer = >
— 21:38
It captured the removal of the view! This is state that the image-based snapshot would not have accounted for. So what we’re seeing here is that protocols aren’t the right tool if we want to support both of these snapshot formats. Luckily, we’ve spent four episodes exploring how protocols can be translated into concrete data types to fix this problem. And next time we’ll do just that! References Protocol-Oriented Programming in Swift Apple • Jun 16, 2015 Apple’s eponymous WWDC talk on protocol-oriented programming: At the heart of Swift’s design are two incredibly powerful ideas protocol-oriented programming and first class value semantics. Each of these concepts benefit predictability, performance, and productivity, but together they can change the way we think about programming. Find out how you can apply these ideas to improve the code you write. https://developer.apple.com/videos/play/wwdc2015/408/ uber/ios-snapshot-test-case Uber, previously Facebook Facebook released a snapshot testing framework known as FBSnapshotTestCase back in 2013, and many in the iOS community adopted it. The library gives you an API to assert snapshots of UIView ’s that will take a screenshot of your UI and compare it against a reference image in your repo. If a single pixel is off it will fail the test. Since then Facebook has stopped maintaining it and transfered ownership to Uber. https://github.com/uber/ios-snapshot-test-case Snapshot Testing in Swift Stephen Celis • Sep 1, 2017 Stephen gave an overview of snapshot testing, its benefits, and how one may snapshot Swift data types, walking through a minimal implementation. https://www.stephencelis.com/2017/09/snapshot-testing-in-swift Protocol Witnesses: App Builders 2019 Brandon Williams • May 3, 2019 Brandon gave a talk about “protocol witnesses” at the 2019 App Builders conference. The basics of scraping protocols is covered as well as some interesting examples of where this technique really shines when applied to snapshot testing and animations. Note Protocol-oriented programming is strongly recommended in the Swift community, and Apple has given a lot of guidance on how to use it in your everyday code. However, there has not been a lot of attention on when it is not appropriate, and what to do in that case. We will explore this idea, and show that there is a completely straightforward and mechanical way to translate any protocol into a concrete datatype. Once you do this you can still write your code much like you would with protocols, but all of the complexity inherit in protocols go away. Even more amazing, a new type of composition appears that is difficult to see when dealing with only protocols. We will also demo a real life, open source library that was originally written in the protocol-oriented way, but after running into many problems with the protocols, it was rewritten entirely in this witness-oriented way. The outcome was really surprising, and really powerful. https://www.youtube.com/watch?v=3BVkbWXcFS4 Downloads Sample code 0038-protocol-oriented-library-design-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .