Testing Swift 3 solutions

To evaluate student solutions written in Swift, you need to write tests using XCTest.

Basics

Let's start with a basic exercise. Your student has to implement a HelloWorld class with a function helloWorld() that returns the string "Hello World". For the sake of simplicity, it doesn't print anything.

Here is an example solution:

import Foundation
class HelloWorld {
    func helloWorld() -> String {
        return "Hello World"
    }
}

And this is an example test of the student's code:

import XCTest
@testable import Base

class Evaluate: UMBaseTestCase {
    var hw = HelloWorld()

    func testHelloWorld() {
        XCTAssertEqual(hw.helloWorld(),
                       "Hello World",
                       "You should print'Hello World'")
    }
}

extension Evaluate {
    static var allTests : [(String, (Evaluate) -> () throws -> Void)] {
        return [
            ("testHelloWorld", testHelloWorld)
        ]
    }
}

Let's examine the above test code.

import XCTest
@testable import Base

Your code must include those lines to import the XCTest framework and our module.

class Evaluate: UMBaseTestCase {

}

Your test code must live inside a class named Evaluate. It is, otherwise, just a plain Swift class.

UMBaseTestCase inherits from XCTestCase. It provides some additional helper methods to manipulate Standard Input and Output.
The Evaluate class can inherit from UMBaseTestCase or directly XCTestCase.

extension Evaluate {
    static var allTests : [(String, (Evaluate) -> () throws -> Void)] {
        return [
            ("testHelloWorld", testHelloWorld)
        ]
    }
}

In order to run your test methods, you must add all of them to allTests.

XCTAssertEqual is a method provided by XCTest which asserts that two expressions have the same value. You can use it with 3 arguments:

  • Actual value
  • Expected value
  • Feedback message to be shown if values are not equal

You can see other assertions here.

Our test class has a test case testHelloWorld() which asserts equality of "Hello World" and the return value of the student's function.

Testing outputs

Let's change the previous example a little bit and ask students to print "Hello World" rather than return the string.

Here is an example solution:

class HelloWorld {
    func helloWorld() {
        print("Hello World")
    }
}

To be able to test the student code, you must be able to read standard output after calling helloWorld(). For this purpose, we have provided helper methods on UMBaseTestCase.

For example:

import XCTest
@testable import Base

class Evaluate: UMBaseTestCase {
    var hw = HelloWorld()

    func testHelloWorld(){
        self.prepareStdOut()
        hw.helloWorld()
        XCTAssertEqual(self.getOutput(),
                       "Hello World\n",
                       "You should print'Hello World'")
    }
}

extension Evaluate {
    static var allTests : [(String, (Evaluate) -> () throws -> Void)] {
        return [
            ("testHelloWorld", testHelloWorld)
        ]
    }
}

There are a few differences between this test and the previous one:

  • We are using helper methods to gain access to data written to standard output.
  • Calling self.prepareStdOut() redirects standard output to a temporary log file to be able to read later.
  • Comparing the "Hello World" string with the output that we read with self.getOutput().
  • The getOutput() method reads the output from the log file and restores the standard output to previous state, therefore we can get feedback from test results.

You can see more details about helper methods below in other examples.

Providing inputs

How do you test whether students are able to read input from standard input?

Consider that the student's task is to read a customer's first and last name from standard input and print the full name to standard output.

The solution for this problem can be implemented as follows:

import Foundation
class Customer {
    var name:String!

    func getName() -> String {

        print("Enter your name:")
        self.name = self.readString()
        return self.name
    }

    private func readString() -> String {
        let str = String(data: FileHandle.standardInput.availableData,
                         encoding:String.Encoding.utf8)!
            .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
        return str
    }
}

UMBaseTestCase has a method to fill standard input with given values.

func setStdInput(input:)

inputs: String input to be entered to standard input. A line separator will be appended to each input.

The following test class enters name in the console and checks whether the student's code can return the name.

import XCTest
@testable import Base

class Evaluate: UMBaseTestCase {
    var customer = Customer()

    func testGetName(){
        let str = "Albert Einstein"
        self.setStdInput(input:str)
        var name = customer.getName()
        XCTAssertEqual(name,
                       "Albert Einstein",
                       "For inputs 'Albert Einstein' it doesn't work.")
    }
}

extension Evaluate {
    static var allTests : [(String, (Evaluate) -> () throws -> Void)] {
        return [
            ("testGetName", testGetName)
        ]
    }
}

Testing exceptions

You can expect student code to raise exceptions in some cases.

So let's improve the example above and ask students to throw IllegalArgumentException if the input passed to a function is not valid. setSSN() won't accept SSNs shorter than 10 characters and setAge() won't accept negative ages. And if the age is negative, the exception message must be "Age can't be a negative number.".

The solution for this problem can be implemented as follows:

enum CustomerError: Error {
    case IllegalArgumentError
}

class Customer {
    var SSN:String?
    var age:Int?

    func setSSN(ssn:String) throws {
        if (ssn.characters.count < 10) {
            throw CustomerError.IllegalArgumentError
        }
        self.SSN = ssn
    }

    func setAge(age:Int) throws {
        if (age < 0) {
            // Error with message
            throw CustomerError.IllegalArgumentError
        }
        self.age = age
    }
}

To check whether the student handled exception cases, you can use XCTAssertThrowsError. The following test class is checking exceptions.

import XCTest
@testable import Base

class Evaluate: UMBaseTestCase {
    var customer = Customer()

    func testInvalidSSN() {
        // Check if it raises exception for invalid Social Security Number
        XCTAssertThrowsError(try customer.setSSN(ssn:"123"),
                             "You should throw IllegalArgumentError")
    }

    func testInvalidAge() {
        // Check if it raises exception for negative age
        XCTAssertThrowsError(try customer.setAge(age:-5),
                             "You should throw IllegalArgumentError")
    }
}

extension Evaluate {
    static var allTests : [(String, (Evaluate) -> () throws -> Void)] {
        return [
            ("testInvalidSSN", testInvalidSSN),
            ("testInvalidAge", testInvalidAge)
        ]
    }
}

You can also use a try / catch block to check whether an exception is thrown and call XCTFail if it is not.

UMBaseTestCase

Let's see all the details of the UMBaseTestCase class which is provided by Udemy to help you deal with reading outputs and entering inputs for student solutions.

import XCTest
import Foundation
@testable import Base

class UMBaseTestCase: XCTestCase {

    var savedStdOut:Int32!
    var savedStdIn:Int32!

    let stdInLogFile = "/eval/stdIn.log"
    let stdOutLogFile = "/eval/stdOut.log"

    var isStdOutRedirected:Bool!
    var isStdInRedirected:Bool!

    override func setUp() {
        super.setUp()
        isStdOutRedirected = false
        isStdInRedirected = false
    }

    override func tearDown(){
        super.tearDown()
        if(isStdOutRedirected == true){
            self.restoreStdOutToDefault()
        }
        if(isStdInRedirected == true){
            self.restoreStdInToDefault()
        }
    }

    //Redirect StandardInput in order to read from log file
    func setStdInput(input:String){
        do {
            try input.write(toFile:stdInLogFile, atomically: true, encoding: String.Encoding.utf8)
        } catch {
            // failed to write file
        }
        isStdInRedirected = true
        savedStdIn = dup(STDIN_FILENO)
        freopen(stdInLogFile, "r", stdin)
    }

    //Redirect StandardOutput to log file
    func prepareStdOut() {
        isStdOutRedirected = true
        savedStdOut = dup(STDOUT_FILENO)
        freopen(stdOutLogFile, "w", stdout)
    }

    //Read from StandardOutput from log file
    func getOutput()->String {
        //Restrore standartOutput
        //This is important to restore to get Test results
        self.restoreStdOutToDefault()
        let content = try! String(contentsOfFile: stdOutLogFile, encoding: String.Encoding.utf8)
        return content
    }

    private func restoreStdOutToDefault(){
        isStdOutRedirected = false
        fflush(stdout)
        dup2(savedStdOut, STDOUT_FILENO)
        close(savedStdOut)
    }

    private func restoreStdInToDefault(){
        isStdInRedirected = false
        dup2(savedStdIn, STDIN_FILENO)
        close(savedStdIn)
    }
}

results matching ""

    No results matching ""