TDD with Realm - A deep-dive

February 27, 2019 ยท 12 minute read

Create an iOS project

First, create a new iOS project with Xcode. Open the IDE and select File | New | Project.

Make sure “Use Core Data” is not selected, since we are going to use Realm to store data, and that you have “Include Unit Tests” selected.

Adding Realm to Your Project

We’ll use CocoaPods to download Realm, so make sure you have it installed. If you already have it installed, update your repo.

$ pod repo update

Create a Podfile

Create a new file named ‘podfile’ in the root directory of the new iOS project. The podfile is a plain text file so you can use whatever text editor you want. Add the following as the contents of your podfile, you can remove the test target if you don’t plan to do unit tests:

platform :ios, '12.0'

target 'YourProjectName' do
  use_frameworks!
  pod 'RealmSwift'
  target 'YourProjectNameTests' do
    use_frameworks!
  end
end

Then install RealmSwift:

$ pod install

When the install is completed, it will create a .xcworkspace file - You will use this file to build your app from now on. Open it up and build your project by doing the command: Command + B. This will finish up any housekeeping in your project that Realm needs to do.

Create an Object

Add a swift file and give it the name of your class. We will need to import RealmSwift and make the class inherit the Object library from it.

import RealmSwift 

class Note: Object
{
    @objc dynamic var id = UUID().uuidString
    @objc dynamic var title: String = ""
    @objc dynamic var content: String = ""
    @objc dynamic var image: String? = nil 
    let tags = List<String?>()
    
    convenience init(id: String, title: String, content: String, image: String? = nil, tag: String? = nil)
    {
        self.init()
        self.id = id
        self.title = title
        self.content = content
        self.image = image
        self.tags.append(tag)
    }
    
    // override primaryKey to uniquely identify your notes how you want to
    override static func primaryKey() -> String?
    {
        return "id"
    }
}

CRUD Tests

Set up the tests by configuring Realm to use an in-memory database, and setting the identifier to the test instance’s name. Then create the database and pass it to the NoteManager.

By using an in-memory Realm instance identified by the name of the current test, it ensures that each test can’t accidentally access or modify the data from other tests of by the application itself. Since they are in-memory, there’s nothing that needs to be cleaned up.

import XCTest
import RealmSwift
@testable import HealthTab
//import HealthTab

class NoteManagerTests: XCTestCase
{
	let sut = NoteManager()

    override func setUp()
    {
        super.setUp()
        // Use an in-memory Realm identified by the name of the current test.
        // This ensures that each test can't accidentally access or modify the data
        // from other tests or the application itself, and because they're in-memory,
        // there's nothing that needs to be cleaned up.
        Realm.Configuration.defaultConfiguration.inMemoryIdentifier = self.name
        
        let realm = try! Realm()
        sut.realm = realm
    }
    

Now we are ready to create tests to implement the CRUD actions of our manager. Here are some examples of tests that I created for my actions using the test-driven development practices:

	
	func test_NoteCount_Initially_ShouldBeZero()
    {
         XCTAssertEqual(sut.noteCount, 0, "count should be 0")
    }
    
    func test_NoteCount_AfterAddingNewNote()
    {
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(note)
        
        XCTAssertEqual(sut.noteCount, 1, "count should be 1")
    }
    
    func test_FollowUpCount_Initially_ShouldBeZero()
    {
        XCTAssertEqual(sut.followUpCount, 0, "follow up flag count should be zero")
    }
    
    func test_FollowUpCount_AfterAddingNotes()
    {
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(note)
        let note2 = sut.makeNote(title: "Foo", content: "Bar", followUp: true, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(note2)
        
        XCTAssertEqual(sut.followUpCount, 1, "follow up count should be 1")
    }
    
    //MARK: - CRUD Tests -
    // MARK: Create
    func test_CreatesNote()
    {
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        
        XCTAssertEqual(note.title, "Foo", "should create a note with data given")
        XCTAssertEqual(note.tags.last, "pig", "should add all the tags")
        XCTAssertEqual(note.image, nil, "should accept nil values")
    }
    
    func test_ImageDirectoryPath_CheckIfCreated()
    {
        let fileManagerExpectation = expectation(description: "Wait for FileManager")
        
        let path = sut.checkImageDirectoryPath()
        sleep(2)
    
        fileManagerExpectation.fulfill()
        waitForExpectations(timeout: 30, handler: nil)
        
        XCTAssertNotNil(path, "the path exists created or exists")
        XCTAssertNotEqual(path, "badpath", "the directory path is broken")
    }

    func test_ImageInAppDirectory_ImageURLSavedInNote()
    {
        let image = createAnImageOf(size: CGSize(width: 200, height: 400))
        
        let fileName = sut.storeImagePathInAppDirectoryFrom(image: image!)
        sleep(5)
        print("CREATED FILENAME: \(String(describing: fileName))")
        imagePaths.append(fileName!)
        
        XCTAssertNotNil(fileName, "Should create a file name")
        
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: fileName!, color: "000000", tags: [nil])
        
        do
        {
            try sut.saveNote(note)
            let notes = sut.realm?.objects(Note.self)
            
            XCTAssertEqual(notes?.first?.image, fileName, "should have the image's file name and suffix saved")
        }
        catch RuntimeError.NoRealmSet
        {
        XCTAssert(false, "No realm database was set")
        }
        catch
        {
        XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_ImageFileName_ConvertsToUIImage()
    {
        let image = createAnImageOf(size: CGSize(width: 200, height: 400))
        
        let fileName = sut.storeImagePathInAppDirectoryFrom(image: image!)
        print("CREATED FILENAME: \(String(describing: fileName))")
        imagePaths.append(fileName!)
        XCTAssertNotNil(fileName, "Should create a file name")
        
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: fileName!, color: "000000", tags: [nil])
        
        do
        {
            try sut.saveNote(note)
            let notes = sut.realm?.objects(Note.self)
            let savedImageName = notes?.first?.image
            
            XCTAssertEqual(savedImageName, fileName, "should have the image's name and suffix saved")
            
            let gotImage = sut.loadImageFrom(fileName: savedImageName!)
            XCTAssertNotNil(gotImage, "file name should have gotten the image data")
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_RemoveImage_FromNoteIfImageIsDeleted()
    {
        let image = createAnImageOf(size: CGSize(width: 200, height: 400))
        
        let fileName = sut.storeImagePathInAppDirectoryFrom(image: image!)
        print("CREATED FILENAME: \(String(describing: fileName))")
        XCTAssertNotNil(fileName, "Should create a file name")
        
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: fileName!, color: "000000", tags: [nil])
        
        do
        {
            try sut.saveNote(note)
            let notes = sut.realm?.objects(Note.self)
            let retrievedNote = notes?.first
            let savedImageName = retrievedNote!.image
            
            XCTAssertEqual(savedImageName, fileName, "should have the image's name and suffix saved")
            
            sut.removeAnyImagePaths(from: notes!)
            let path = sut.checkImageDirectoryPath() + "/" + fileName!
            
            XCTAssertFalse(sut.checkIfDirectoryExistFor(fullPath: path), "should remove image path and data from app directory")
            
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_SavesNote_WithRuntimeError()
    {
        do
        {
            let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
            
            try sut.saveNote(note)
            let notes = sut.realm?.objects(Note.self)
            
            XCTAssertEqual(notes?.count, 1, "should add a new note to the database")
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    // MARK: Read
    func test_GetAllNotes()
    {
        let first = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(first)
        
        let second = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(second)
        
        let allNotes = try! sut.getAllNotes()
        
        XCTAssertEqual(allNotes.count, 2, "should get all notes in the database")
    }
    
    func test_FindNote_ByField()
    {
        let first = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
        try! sut.saveNote(first)
        
        let notes = sut.realm?.objects(Note.self)
        let firstPrimaryKey = notes?.first?.id
        
        let second = sut.makeNote(title: "Rickmancing The Stone", content: "The Stone", followUp: true, image: nil, color: "000000", tags: ["bruh", "canabals"])
        try! sut.saveNote(second)
        
        let fetchedNoteByID = try! sut.findNoteByField("id", value: firstPrimaryKey!)
        XCTAssertEqual(fetchedNoteByID.first?.id, first.id, "should find a note using its id or primary key")
        
        let fetchedNoteByTitle = try! sut.findNoteByField("title", value: "Rickmancing The Stone")
        XCTAssertEqual(fetchedNoteByTitle.first?.tags.first, "bruh", "should find a note by using its title")
    }
    
    func test_FindNote_WithLoadedAndRescaledImage()
    {
        let image = createAnImageOf(size: CGSize(width: 340, height: 340))
        let imagePath = sut.storeImagePathInAppDirectoryFrom(image: image!)
        print("CREATED FILENAME: \(String(describing: imagePath))")
        imagePaths.append(imagePath!)
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: true, image: imagePath!, color: "111111", tags: [nil])
        
        try! sut.saveNote(note)
        
        let returnedNote = try! sut.getNoteAt(primaryKey: note.id, rescaleImage: true)
        
        XCTAssertEqual(returnedNote.image!.size.width, sut.maxWidth, "image should rescale to 170 if its width is greater than 170")
    }
    
    func test_FindNote_WillNotRescaleImageIfAt_CorrectWidth()
    {
        let image = createAnImageOf(size: CGSize(width: sut.maxWidth, height: 340))
        let imagePath = sut.storeImagePathInAppDirectoryFrom(image: image!)
        print("CREATED FILENAME: \(String(describing: imagePath))")
        imagePaths.append(imagePath!)
        let note = sut.makeNote(title: "Foo", content: "Bar", followUp: true, image: imagePath!, color: "111111", tags: [nil])
        
        try! sut.saveNote(note)
        
        let returnedNote = try! sut.getNoteAt(primaryKey: note.id, rescaleImage: true)
        
        XCTAssertEqual(returnedNote.image!.size.width, sut.maxWidth, "image should not rescale if its width equals max width")
    }
    
    // MARK: Update
    func test_UpdateNote_ForOneSpecifiedProperty()
    {
        do
        {
            let newNote = sut.makeNote(title: "Rick's Sayings", content: "Bar", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
            
            try sut.saveNote(newNote)
            
            let foundNotes = try sut.findNoteByField("id", value: newNote.id)
            
            XCTAssertEqual(foundNotes.first?.content, "Bar", "sould save a note and retrieve it")
            
            try sut.updateNotes(primaryKey: newNote.id, field: "content", updatedValue: "A vat of reduntancy")
         
            let changedNotes = try sut.findNoteByField("id", value: newNote.id)
            XCTAssertEqual(changedNotes.count, 1, "Should only be one note in the database")
            
            let changedNote = changedNotes.first
            XCTAssertEqual(changedNote?.content, "A vat of reduntancy", "should have updated the property of an existing note")
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_UpdateNote_ByAssigningOriginalPrimaryKey()
    {
        do
        {
            let newNote = sut.makeNote(title: "Rick's Sayings", content: "What happened? Did you fall into a vat of redundancy?", followUp: false, image: nil, color: "000000", tags: ["man", "bear", "pig"])
            print("here's the original note's id: \(newNote.id)")
            
            try sut.saveNote(newNote)
            
            let foundNotes = try sut.findNoteByField("id", value: newNote.id)
            
            XCTAssertEqual(foundNotes.first?.content, "What happened? Did you fall into a vat of redundancy?", "sould save a note and retrieve it")
            
            let editNote = sut.makeNote(title: "Noobnoobs Quip", content: "Goddamn!", followUp: true, image: nil, color: "000000", tags: ["man", "pig"])
            
            print(" here;s the original note edited: \(editNote.id)")
            
            editNote.id = newNote.id
            
            print("after updating the updated note with the original id: \(editNote.id)")
            
            try sut.saveNote(editNote)
            
            
            let foundEdittedNote = try sut.findNoteByField("id", value: newNote.id)
            
            XCTAssertEqual(foundEdittedNote.first?.content, "Goddamn!", "sould save an editted note and retrieve it")
        
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_UpdateNote_ReplacesExistingImageWithNewImage()
    {
        
    }
   
    // MARK: Delete
    func test_DeleteNote_ByPrimaryKey()
    {
        do
        {
            let image = createAnImageOf(size: CGSize(width: 340, height: 340))
            let fileName = sut.storeImagePathInAppDirectoryFrom(image: image!)
            let imagePath = sut.checkImageDirectoryPath() + "/" + fileName!
            let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: fileName!, color: "000000", tags: ["man", "bear", "pig"])
            
            try sut.saveNote(note)
            
            let notes = sut.realm?.objects(Note.self)
          
            XCTAssertEqual(notes?.count, 1, "should add a new note to the database")
            XCTAssertNotNil(notes?.first?.image, "should have image path in file manager")
            
            try sut.delete(note)
            
            let notesAfterDelete = sut.realm?.objects(Note.self)
            
            XCTAssertEqual(notesAfterDelete?.count, 0, "should remove note")
            XCTAssertFalse(FileManager.default.fileExists(atPath: imagePath), "there shouldn't be a file with this image path")
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
    
    func test_DeleteAllNotes()
    {
        do
        {
            let image = createAnImageOf(size: CGSize(width: 340, height: 340))
            let secondImage = createAnImageOf(size: CGSize(width: 540, height: 340))
            let imagePath = sut.storeImagePathInAppDirectoryFrom(image: image!)
            let imagePath2 = sut.storeImagePathInAppDirectoryFrom(image: secondImage!)
            
            let note = sut.makeNote(title: "Foo", content: "Bar", followUp: false, image: imagePath!, color: "000000", tags: ["man", "bear", "pig"])
            let note2 = sut.makeNote(title: "Mike", content: "Bar", followUp: false, image: imagePath2!, color: "000000", tags: [nil])
            
            try sut.saveNote(note)
            try sut.saveNote(note2)
            
            let notes = sut.realm?.objects(Note.self)
            
            XCTAssertEqual(notes?.count, 2, "should add a new notes to the database")
            XCTAssertNotNil(notes?.first?.image, "should have image path in file manager")
            XCTAssertNotNil(notes?.last?.image, "should have image path in file manager")
            
            try sut.deleteAll()
            
            let notesAfterDelete = sut.realm?.objects(Note.self)
            
            XCTAssertEqual(notesAfterDelete?.count, 0, "should remove all notes")
            XCTAssertFalse(FileManager.default.fileExists(atPath: imagePath!), "there shouldn't be a file with this image path")
            XCTAssertFalse(FileManager.default.fileExists(atPath: imagePath2!), "there shouldn't be a file with this image path")
        }
        catch RuntimeError.NoRealmSet
        {
            XCTAssert(false, "No realm database was set")
        }
        catch
        {
            XCTAssert(false, "Unexpected error \(error)")
        }
    }
}

// MARK: - Extensions -
extension NoteManagerTests
{
    class MockNoteManager: NoteManager
    {
        override func storeImagePathInAppDirectoryFrom(image: UIImage) -> String?
        {
            return "a/path/was/returned"
        }
    }
}

extension Realm
{
    public func safeWrite(_ block:(() throws -> Void)) throws
    {
        if isInWriteTransaction
        {
            try block()
        } else {
            try write(block)
        }
    }
}

Implement CRUD Actions

We now need code to handle all the CRUD (create, read, update, delete) actions on the database. This is how the final class’ object manager, NoteManager, will be set up. The correct way to do it is to create tests first and then build out the methods to have the tests pass. This is what I developed to make them pass.

import Foundation
import UIKit
import RealmSwift
import os.log

enum RuntimeError: Error
{
    case NoRealmSet
}

class NoteManager: NSObject
{
	// have realm accept a Realm instance instead of creating one
    var realm: Realm?  
    var maxWidth: CGFloat = 170
    var documentsURL: URL {
        return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
    }
    
    var noteCount: Int
    {
        return realm != nil ? try! (getAllNotes().count) : 0
    }
    
    var followUpCount: Int
    {
        var count = 0
        if realm != nil
        {
            let notes = try! getAllNotes()
            
            for note in notes
            {
                if note.followUp == true { count += 1 }
            }
        }
        return count
    }
    
    // MARK: - CREATE
    public func makeNote(title: String, content: String, followUp: Bool, image: String?, color: String?, tags: [String?]) -> Note
    {
        let newNote = Note()
        newNote.title = title
        newNote.content = content
        newNote.followUp = followUp
        newNote.image = image
        newNote.categoryColor = color
        for tag in tags
        {
            newNote.tags.append(tag)
        }
        return newNote
    }
    
    public func saveNote(_ note: Note) throws
    {
        do
        {
            realm = try Realm()
            try realm!.write {
                realm!.add(note, update: true)
            }
        }
        catch RuntimeError.NoRealmSet
        {
            os_log("no realm database")
        }
        catch
        {
           print("something else went wrong with saving a note")
        }
    }
    
    // MARK: - READ
    public func getAllNotes() throws -> Results<Note>
    {
        do
        {
            realm = try Realm()
            return realm!.objects(Note.self)
        }
        catch
        {
            throw RuntimeError.NoRealmSet
        }
    }
    
    public func findNoteByField(_ field: String, value: String) throws -> Results<Note>
    {
        do
        {
            realm = try Realm()
            let predicate = NSPredicate(format: "%K = %@", field, value)
            return realm!.objects(Note.self).filter(predicate)
        }
        catch
        {
            throw RuntimeError.NoRealmSet
        }
    }
    
    public func getNoteAt(primaryKey: String, rescaleImage: Bool = false) throws -> (note: Note, image: UIImage?)
    {
        var image: UIImage? = nil
        var notes: Results<Note>
        do
        {
            realm = try Realm()
            notes = try findNoteByField("id", value: primaryKey)
        }
        catch
        {
            throw RuntimeError.NoRealmSet
        }
        
        let note = notes.first
        if note?.image != nil
        {
            image = loadImageFrom(fileName: note!.image!)

            if rescaleImage == true && (image!.size.width) > maxWidth
            {
                let scaledDownImage = scaleImageTo(maximumWidth: maxWidth, image: image!)
                image = scaledDownImage
            }
        }
        return (note!, image)
    }
  
    // MARK: - UPDATE
    public func updateNotes(primaryKey: String, field: String, updatedValue: Any) throws
    {
        let notes = try findNoteByField("id", value: primaryKey)
        try! realm!.write {
            notes.setValue(updatedValue, forKeyPath: "\(field)")
        }
    }
    
    // MARK: - DELETE
    public func delete(_ note: Note) throws
    {
        //Remove imagepath, if exists
        if note.image != nil
        {
            let fileName = note.image!
            let imagePath = checkImageDirectoryPath() + "/" + fileName
            do
            {
                let fileManager = FileManager.default
                if fileManager.fileExists(atPath: imagePath)
                {
                    try fileManager.removeItem(atPath: imagePath)
                }
                else
                {
                    os_log("File does not exist", log: .default, type: .debug)
                }
            }
            catch
            {
                print("A serious issue occurred when trying to delete an image from a single note: \(error)")
            }
        }
        //THEN remove the note from Realm
        do
        {
            realm = try Realm()
            try! realm!.write
            {
                realm!.delete(note)
            }
        }
        catch
        {
            throw RuntimeError.NoRealmSet
        }
    }
    
    public func deleteAll() throws
    {
        do
        {
            let notes = realm!.objects(Note.self)
            removeAnyImagePaths(from: notes)
            
            realm = try Realm()
            try! realm!.write
            {
                realm!.deleteAll()
            }
        }
        catch
        {
            throw RuntimeError.NoRealmSet
        }
    }
}