Swiftでテストコードを書くための手順を紹介しようと思います。
今回はreloadボタンを押すと、内部で1から3の乱数が発生し、1であれば「晴れ」の画像、2であれば「曇り」の画像、3であれば「雨」の画像を表示するといった簡単なアプリを実装します。またそれに伴い、各数字に対応する画像が表示されているかどうかの単体テストを行なってみようと思います。なお、storyboardの実装は省略しているため、WeatherView.storyboardを実装しておいてください。それでは実際の手順です。
①まずは何も考えずに実装
import UIKit
class WeatherViewController: UIViewController {
@IBOutlet weak var weatherImage: UIImageView!
@IBAction func reloadWeatherImage(_ sender: Any) {
updateWeather()
}
func updateWeather() {
let num = Int.random(in: 1...3)
switch num {
case 1:
weatherImage.image = UIImage(named: "sunny")
case 2:
weatherImage.image = UIImage(named: "cloudy")
case 3:
weatherImage.image = UIImage(named: "rainy")
default:
weatherImage.image = UIImage(named: "sunny")
}
}
}
②責務を切り分ける
SwiftではUIの部分とロジックの部分を切り分ける(別クラスで定義する)ことが必要です。このような小規模な実装では、ただコード量が増えてしまっているだけのように見えますが、大規模な開発になると、この考え方が非常に重要になってきます。是非この機会でマスターしていってください。
ロジック
class WeatherModelImpl {
let weatherViewController = WeatherViewController()
func fetchWeather() {
let num = Int.random(in: 1...3)
switch num {
case 1:
weatherViewController.updateWeather(weather: "sunny")
case 2:
weatherViewController.updateWeather(weather: "cloudy")
case 3:
weatherViewController.updateWeather(weather: "rainy")
default:
weatherViewController.updateWeather(weather: "sunny")
}
}
}
UIの更新
import UIKit
class WeatherViewController: UIViewController {
let weatherModelImpl = WeatherModelImpl()
@IBOutlet weak var weatherImage: UIImageView!
@IBAction func reloadWeatherImage(_ sender: Any) {
weatherModel.fetchWeather()
}
func updateWeather(weather: String) {
weatherImage.image = UIImage(named: weather)
}
}
お互いのインスタンスを持っている状況なので、「お互いに依存している」状況と言えます。しかし、これではテストコードを書くことができません。つまり、依存先をクラスからプロトコルに変えなければなりません。
③delegateパターンを適用
ここから少し難しいですが、コードを眺めてみてください。
ロジック
protocol WeatherModelDelegate: AnyObject {
func updateWeather(weatherModel: WeatherModel, weather: String)
}
protocol WeatherModel {
var delegate: WeatherModelDelegate? { get set }
func fetchWeather()
}
class WeatherModelImpl: WeatherModel {
weak var delegate: WeatherModelDelegate?
func fetchWeather() {
let num = Int.random(in: 1...3)
switch num {
case 1:
delegate?.updateWeather(weatherModel: self, weather: "sunny")
case 2:
delegate?.updateWeather(weatherModel: self, weather: "cloudy")
case 3:
delegate?.updateWeather(weatherModel: self, weather: "rainy")
// 特にここは重要ではないので、デフォルトで晴れの画像を出力するようにしています
default:
delegate?.updateWeather(weatherModel: self, weather: "sunny")
}
}
}
delegateというのは「委譲する」という意味で、今回は画像の更新を委譲しています。プロトコルに準拠したプロパティを持つことで、ModelはViewControllerのインスタンスを持つ必要が無くなりました。
UIの更新
import UIKit
class WeatherViewController: UIViewController {
var weatherModel: WeatherModel
@IBOutlet weak var weatherImage: UIImageView!
@IBAction func reloadWeatherImage(_ sender: Any) {
weatherModel.fetchWeather()
}
static func getInstance(weatherModel: WeatherModel) -> WeatherViewController? {
let storyboard = UIStoryboard(name: "WeatherView", bundle: nil)
let weatherViewController = storyboard.instantiateInitialViewController { coder in
WeatherViewController(coder: coder, weatherModel: weatherModel)
}
return weatherViewController
}
init?(coder: NSCoder, weatherModel: WeatherModel) {
self.weatherModel = weatherModel
super.init(coder: coder)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
weatherModel.delegate = self
}
}
extension WeatherViewController: WeatherModelDelegate {
func updateWeather(weatherModel: WeatherModel, weather: String) {
weatherImage.image = UIImage(named: weather)
}
}
ポイントは初期化の部分で、これを DI(依存性の注入) と言います。難しい表現をしていますが、要はweatherModelが外から差し込めるようになり、任意のweatherModelに対してWeatherViewControllerが更新されるようになったのです。getInstanceではWeatherViewControllerのインスタンスを返すようにしています。WeatherViewControllerのUIが正しく更新されるかどうかのテストを書くため、当然必要な関数です。
④テストコードを書いてみる
③のコードに対して、テストコードを書いてみます。
import XCTest
@testable import プロジェクト名
class WeatherViewControllerTest: XCTestCase {
var weatherViewController: WeatherViewController!
let mock = WeatherModelMock()
override func setUp() {
super.setUp()
weatherViewController = WeatherViewController.getInstance(weatherModel: mock)
weatherViewController.loadViewIfNeeded()
}
override func tearDown() {
super.tearDown()
weatherViewController = nil
}
func testSunny() {
compareWithImage(weatherCondition: "sunny")
}
func testCloudy() {
compareWithImage(weatherCondition: "cloudy")
}
func testRainy() {
compareWithImage(weatherCondition: "rainy")
}
private func compareWithImage(weatherCondition: String) {
mock.weatherCondition = weatherCondition
mock.fetchWeather()
XCTAssertEqual(weatherViewController.weatherImage.image, UIImage(named: weatherCondition)?.withRenderingMode(.alwaysTemplate))
}
}
class WeatherModelMock: WeatherModel {
weak var delegate: WeatherModelDelegate?
var weather: String = ""
func fetchWeather() {
delegate?.updateWeather(self, weather: weather)
}
}
先ほど述べた DI により、WeatherModelMock(テストに使う用のクラス)をWeatherViewControllerに差し込めるようになったのです。また、XCTestというライブラリにはテスト用のメソッドが他にもたくさん用意されているので、気になる方は是非見てください。
分からない点や間違った点などあれば、コメントお待ちしております!!