My thoughts on TDD

A natural workflow for software developers is to code and then conduct some manual tests to make sure things are working correctly. While this approach works, it also has some issues:

  • It’s error-prone (the tests are manually conducted)
  • We normally only test a small portion of the app (the part under development)

To be fair, manually testing only the part under development is not the problem. The problem is that we normally don’t conduct all previous tests for the other features. We might introduce a breaking change without noticing it.

Beyond manual tests, there are other tools we can use to ensure the correctness of our code. One of them is unit tests. They ensure each component in our code performs correctly in isolation, increasing the overall quality of the software we write.

TDD is a workflow that builds on top of automated tests in general. I don’t think it works in every scenario, but when used with unit tests this technique shines:

  • It gives us instant and reliable feedback on the changes we make
  • It disciplines us to write tests covering the features we develop
  • We are informed if we break an existing functionality

I’ve used TDD in the second and third assignments of the CS193P course. These assignments ask the students to build two card games for iOS. I began the development by the model (which contains the game logic), and I didn’t need to write the UI layer to only then manually test the business logic. Having the tests in place also gave me confidence that it worked. Here is a commit that shows this workflow in action (Each feature has some related tests ensuring it works).

TDD Cons

It’s hard to use this technique if:

  • We don’t have a good understanding of the project
  • We have a tight schedule
  • We are writing UI or integration tests (those take time to write and run and might give different results)

Many of TDD’s benefits come from the fact that it enforces us to write automated tests. If we get into the habit of always writing tests for the changes we make, we are also in a good shape.

Persistent Image Gallery (CS193p fall of 2017, assignment VI solution)

The assignment VI  asked the student to convert the ImageGallery app from the previous assignment (V) and make it support the new Files API, using a UIDocumentBrowserViewController instance to present the documents.

I enjoyed the new Apple’s API and solution to deal with documents in iOS. Now an app can add integration to the new Files app, the documents you interact with now provide autosave, asynchronous open and close operations and they also take care of storage for you (filesystem, iCloud). Not to mention the new UIDocumentBrowserViewController class, which handles all common document operations just like the Files app does, and provides a similar interface. You can also export your own type, marking your app as the owner and editor.

This assignment also asked the student to provide caching for the image fetching requests. I’ve done so using the URLCache class and providing it to my own URLSessionConfiguration.

Main challenges

To deal with documents I had to create my own instance of the UIDocument class, which handles an instance of my ImageGallery struct. I had already written the code necessary to convert the model to JSON, pretty simple with the new Codable API.

class ImageGalleryDocument: UIDocument {
  
  // MARK: - Properties
  
  /// The document thumbnail.
  var thumbnail: UIImage?
  
  /// The gallery stored by this document.
  var gallery: ImageGallery?
  
  // MARK: - Life cycle
  
  override func contents(forType typeName: String) throws -> Any {
    return gallery?.json ?? Data()
  }
  
  override func load(fromContents contents: Any, ofType typeName: String?) throws {
    if let data = contents as? Data {
      gallery = ImageGallery(json: data)
    }
  }
  
  override func fileAttributesToWrite(to url: URL, for saveOperation: UIDocumentSaveOperation) throws -> [AnyHashable : Any] {
    var attributes = try super.fileAttributesToWrite(to: url, for: saveOperation)
    if let thumbnail = thumbnail {
      attributes[URLResourceKey.thumbnailDictionaryKey] = [URLThumbnailDictionaryItem.NSThumbnail1024x1024SizeKey : thumbnail]
    }
    
    return attributes
  }
}

In the gallery display controller, I’ve added properties to hold the new document. All I had to do was to open, change and save it.

  // ...
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    galleryDocument?.open { success in
      if success {
        if self.gallery == nil {
          self.gallery = self.galleryDocument!.gallery
          self.gallery.title = self.galleryDocument!.localizedName
        }
      } else {
        self.presentWarningWith(title: "Error", message: "Document can't be viewed.") {
          self.dismiss(animated: true)
        }
      }
    }
  }

  // ...

  @IBAction func didTapDone(_ sender: UIBarButtonItem) {
    galleryDocument?.gallery = gallery
    
    if !cachedImages.isEmpty {
      galleryDocument?.thumbnail = cachedImages.first?.value
    }
    
    galleryDocument?.updateChangeCount(.done)
    galleryDocument?.close() { success in
      if !success {
        self.presentWarningWith(title: "Error", message: "The document can't be saved.") {
          self.dismiss(animated: true)
        }
      } else {
        self.dismiss(animated: true)
      }
    }
  }

To provide support for caching, I’ve created a class handling all image fetching requests. Inside this class, I’ve configured a specific URLSession.

/// The session used to make each data task.
private(set) lazy var session: URLSession = {
  let cache = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 80 * 1024 * 1024, diskPath: nil)
    
  let configuration = URLSessionConfiguration.default
  configuration.urlCache = cache
  configuration.requestCachePolicy = .returnCacheDataElseLoad
    
  return URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
}()
  
// MARK: - Imperatives
  
/// Requests an image at the provided URL.
func request(
  at url: URL,
  withCompletionHandler completion: @escaping (Data) -> (),
  andErrorHandler onError: @escaping (Error?, URLResponse?) -> ()
) {
  let task = session.dataTask(with: url) { (data, response, transportError) in
    guard transportError == nil, let data = data else {
      onError(transportError, nil)
      return
    }
    
    guard let httpResponse = response as? HTTPURLResponse,
      (200...299).contains(httpResponse.statusCode),
      ["image/jpeg", "image/png"].contains(httpResponse.mimeType) else {
        onError(nil, response)
        return
    }
    completion(data)
  }
  task.resume()
}

The source code for this solution can be found in my Github repository.

Image Gallery (CS193p fall of 2017, assignment V solution)

The assignment V asked the student to create an iPad app which let users drag images from other apps (using multitasking) and create an image gallery from them. Each dragged image needed to be fetched from its URL in order to be presented in the gallery’s collection view. The image galleries can be removed, edited or added from a master controller, which presents each gallery document in a table view. Each image can be displayed in a separate detail controller, which uses a scroll view and an image view to display the selected image.

The main features to be learned were:

  • Table views (to display each gallery as a document)
  • Collection views (to display each image)
  • multithreading (involved in requesting the image’s data)
  • Scroll views (used to display the images in detail, using an Image view as the subview)
  • Text fields (used to rename a gallery from its table view cell)
  • Drag and drop API

Main challenges

The most difficult feature to be added was to support the drop interactions in the gallery’s collection view. I had some problems to correctly add the image to the data source while dismissing the placeholder collection view cell. The drop interactions also involved fetching the image from the loaded URL.

...
// Loads the URL.
_ = item.dragItem.itemProvider.loadObject(ofClass: URL.self) { (provider, error) in
  if let url = provider?.imageURL {
    draggedImage.imagePath = url

    // Downloads the image from the fetched url.
    URLSession(configuration: .default).dataTask(with: url) { (data, response, error) in
      DispatchQueue.main.async {
        if let data = data, let _ = UIImage(data: data) {
          // Adds the image to the data source.
          placeholderContext.commitInsertion { indexPath in
            draggedImage.imageData = data
            self.insertImage(draggedImage, at: indexPath)
          }
        } else {
          // There was an error. Remove the placeholder.
          placeholderContext.deletePlaceholder()
        }
      }
    }.resume()
  }
}

I also had some difficulties in handling the drop from within the app (reordering of images). Hopefully, I had the demo code, which had a similar situation and treatment:

func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
  if collectionView.hasActiveDrag {
    // if the drag is from this collection view, the image isn't needed.
    return session.canLoadObjects(ofClass: URL.self)
  } else {
    return session.canLoadObjects(ofClass: URL.self) && session.canLoadObjects(ofClass: UIImage.self)
  }
}
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
  guard gallery != nil else {
    return UICollectionViewDropProposal(operation: .forbidden)
  }

  // Determines if the drag was initiated from this app, in case of reordering.
  let isDragFromThisApp = (session.localDragSession?.localContext as? UICollectionView) == collectionView
  return UICollectionViewDropProposal(operation: isDragFromThisApp ? .move : .copy, intent: .insertAtDestinationIndexPath)
}

And in the perform drop method:

func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
  let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(item: 0, section: 0)

  for item in coordinator.items {
    if let sourceIndexPath = item.sourceIndexPath {

      // The drag was initiated from this collection view.
      if let galleryImage = item.dragItem.localObject as? ImageGallery.Image {

        collectionView.performBatchUpdates({
          self.gallery.images.remove(at: sourceIndexPath.item)
          self.gallery.images.insert(galleryImage, at: destinationIndexPath.item)
          collectionView.deleteItems(at: [sourceIndexPath])
          collectionView.insertItems(at: [destinationIndexPath])
        })

        coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
      }   
    } else {
      // The drag was initiated from outside of the app.
      ...
    }
  }
}

I’ve completed all the extra credit tasks for this assignment. I’ve added a new class called ImageGalleryStore, which was in charge of persisting the gallery models using the UserDefaults API. The storage mechanism used the NotificationCenter API to communicate the model changes back to the controllers. To make each gallery model become able to be stored I’ve simply added conformance to the Codable protocol.

I’ve also added support to the deletion of images using a drop interaction in a UIBarButtonItem. Since UIBarButtonItems aren’t views (and can’t handle drop interactions because of that), I had to use the custom view initializer, adding a button with the drop interaction attached to it:

override func viewDidLoad() {
  super.viewDidLoad()
  // In order to implement the drop interaction in the navigation bar button,
  // a custom view had to be added, since a UIBarButtonItem is not a
  // view and doesn't handle interactions.

  let trashButton = UIButton()
  trashButton.setImage(UIImage(named: "icon_trash"), for: .normal)  

  let dropInteraction = UIDropInteraction(delegate: self)
  trashButton.addInteraction(dropInteraction)

  let barItem = UIBarButtonItem(customView: trashButton)
  navigationItem.rightBarButtonItem = barItem  

  barItem.customView!.widthAnchor.constraint(equalToConstant: 25).isActive = true
  barItem.customView!.heightAnchor.constraint(equalToConstant: 25).isActive = true
  
  ...
}

The source code for this solution can be found in my Github repository.

Animated Set (CS193p fall of 2017, assignment IV solution)

This project is comprised of two parts: A Set game and a Concentration game, both presented by a UITabBarViewController. The Set game written in this app is a game based on the Set one, which is a popular card game (I’ve used this video to get its business logic). The Concentration is a simple game based on matches of card pairs, which are then removed from the table. All these projects were developed based on the previous assignments (I, II and III).

The fourth assignment required the student to add animations to the following events of the Set game:

  • All cards are smoothly rearranged in the grid, this is done using UIViewPropertyAnimator to animate each card’s frame.
  • All cards are dealt one by one at the beginning of the game and after the discovery of a match. Each card moves from the deck to its appropriate position in the cards grid. As per the requirements, two cards mustn’t be dealt at the same time. The animation uses UIViewPropertyAnimator combined with the UIDynamicAnimator class and a UISnapBehavior.
  • In the case of a match, cards are removed from the grid and put into the matched deck. This removal animation was also written using the UIViewPropertyAnimator, UIDynamicAnimator and UISnapBehavior classes.
  • The flipping over of cards. This animation was written with the transition API in the UIView class.
The usage of a UITabBarController to embed the Set and Concentration controllers was also required, and the Concentration tab also had to use a UISplitViewController, with a theme chooser as the master controller and the game’s controller as the detail one.
 Assignment IV project storyboard

Main challenges

I had some troubles before getting the deal animation working fine, it conflicted with the rearrangement of cards, which uses a UIViewPropertyAnimator to animate each card’s frame to its correct position in the grid. This happened mainly because of both animations modifying the same properties at the same time:

override func layoutSubviews() {
  super.layoutSubviews()
    
  // Only updates the buttons frames if the centered rect has changed,
  // This will occur when orientation changes.
  // This check will prevent frame changes while
  // the dynamic animator is doing it's job.
  if grid.frame != gridRect {
    updateViewsFrames()
  }
}

The deal animation can be triggered by the addition of new cards to the container and can also be independently called by using the dealCardsWithAnimation method in the container view:

/// Adds new buttons to the UI.
/// - Parameter byAmount: The number of buttons to be added.
/// - Parameter animated: Bool indicating if the addition should be animated.
func addButtons(byAmount numberOfButtons: Int = 3, animated: Bool = false) {
  guard isPerformingDealAnimation == false else { return }
    
  let cardButtons = makeButtons(byAmount: numberOfButtons)
    
  for button in cardButtons {
    // Each button is hidden and face down by default.
    button.alpha = 0
    button.isFaceUp = false
      
    addSubview(button)
    buttons.append(button)
  }
    
  grid.cellCount += cardButtons.count
  grid.frame = gridRect
    
  if animated {
    dealCardsWithAnimation()
  }
}
I also had troubles with the device orientation changes while the cards were being dealt. In order to solve this issue, I had to cancel any running animations and rearrange the cards after the transition was done. In the controllers I had the following code:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  // In case the controller is still being presented and the
  // views haven't been instantiated.
  guard containerView != nil else { return }
    
  coordinator.animate(alongsideTransition: { _ in
    self.containerView.prepareForRotation()
  }) { _ in
    self.containerView.updateViewsFrames(withAnimation: true)
  }
}
 And in the container view:
/// Prepares the container for the device's rotation event.
/// Stops any running deal animations and respositions all the views.
func prepareForRotation() {
  animator.removeAllBehaviors()
    
  // Invalidates all scheduled deal animations.
  scheduledDealAnimations?.forEach { timer in
    if timer.isValid {
      timer.invalidate()
    }
  }
    
  positioningAnimator?.stopAnimation(true)
    
  for button in buttons {
    button.transform = .identity
    button.setNeedsDisplay()
  }
    
  isPerformingDealAnimation = false
}

Improvements

There’s some code that still needs refactoring. Also, none of the extra credit requirements regarding the Set game were executed, these are going to be my next steps.

The source code for this solution can be found in my Github repository.