Joesusnick / TDD TableViews - lesss boring than you think!
Essential Developer - Building iOS app with TDD
목차 (눌러서 이동)
Programmatic UI Development 준비
음악 데이터를 가져오면 목록으로 표현할 Table View 가 필요하다. UI 개발에 있어 Storyboard 의 사용 없이 Programmatic 하게 작성하고 싶으므로 몇 가지 준비 작업이 필요하다.
1. Storyboard 제거 (Move to trash 를 클릭해 제거하기.)
2. Project 설정 → General → Deployment info → Main interface field 비워두기.
3. Info.plist → Application Scene Manifest → Scene Configuration → Application Session Role → Item 0 에서 Storyboard Name 을 - 버튼을 눌러 아예 제거하기.
Table View Controller 작성
Root View 지정
Storyboard 를 제거했으므로 SceneDelegate 파일에서 직접 Root View 를 지정하는 코드를 작성한다. 아래는 이를 위한 Failing Test.
func test_is_root_viewController() {
guard let rootVC = UIApplication.shared.windows.first!.rootViewController else { return XCTFail() }
XCTAssertTrue(rootVC is PlayListViewController)
}
작성할 view controller 를 PlayListViewController 라고 하자. 작성한 failing test 에서 원하는 결과는 root view controller 가 PlayListViewController 타입이 되는 것이다.
failing test 를 통과하기 위해 SceneDelegate.swift 파일에서 root view 를 지정하고 PlayListViewController.swift 파일을 생성한다.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = PlayListViewController()
window.makeKeyAndVisible()
self.window = window
}
class PlayListViewController: UIViewController {}
Root View 에 UITableView 프로퍼티 선언
생성한 view controller 에 UITableView 타입의 tableView 프로퍼티를 선언해야 한다. 이를 위한 failing test 에선 PlayListViewController 가 tableView 라는 이름(물론 변수명은 원하는대로 가능하다.)의 UITableView 타입의 변수를 가지는지, 그의 superview 가 PlayListViewController 인지 확인한다.
func test_has_tableView() {
let sut = PlayListViewController()
view = sut.view
XCTAssertTrue(sut.tableView is UITableView)
XCTAssertEqual(sut.tableView.superview, view)
}
UITableView 타입의 tableView 프로퍼티를 선언하고 이를 sub view 로 추가해 test 를 통과한다.
class PlayListViewController: UIViewController {
let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(tableView)
}
}
tableView configurations
Table View 를 사용하기 위해 몇 가지를 설정한다. loadViewIfNeeded() 를 통해 PlayListViewController 의 viewDidLoad() 를 호출할 수 있다.
func test_tableView_configuration() {
let sut = PlayListViewController()
let tableView = sut.tableView
sut.loadViewIfNeeded()
let config =
tableView.allowsSelection &&
tableView.isUserInteractionEnabled &&
!tableView.translatesAutoresizingMaskIntoConstraints
XCTAssertTrue(config)
}
Test 에 작성한 세 가지 값에 대해 알맞은 boolean 값을 부여한다. Table View 설정을 위한 함수를 작성하고 viewDidLoad() 에서 호출한다.
override func viewDidLoad() {
view.addSubview(tableView)
setUpTableView()
}
func setUpTableView() {
tableView.allowsSelection = true
tableView.isUserInteractionEnabled = true
tableView.translatesAutoresizingMaskIntoConstraints = false
}
Refactoring
두 개 이상의 Test 에서 sut 의 생성이 반복되고 있으므로 Test Class 의 Property 로 선언한 뒤, setUpWithError(), tearDownWithError() 함수에서 할당과 해제를 담당하도록 한다. 위 함수는 각 Test 가 실행될 때마다 Test 할 객체가 동일한 상태가 되도록 해준다.
class PlayListViewControllerTests: XCTestCase {
private var sut: PlayListViewController!
override func setUpWithError() throws {
try super.setUpWithError()
sut = PlayListViewController()
}
override func tearDownWithError() throws {
sut = nil
try super.tearDownWithError()
}
tableView 프레임 설정
tableView 의 Frame 이 superView 의 bounds 와 일치하도록 Layout 을 설정한다.
func test_tableView_covers_entire_superView() {
let tableView = sut.tableView
let superView = sut.view!
sut.loadViewIfNeeded()
XCTAssertTrue(tableView.frame == superView.bounds)
}
setUpTableView() 메서드에 다음을 추가하면 tableView 가 전체 화면을 덮도록 할 수 있다.
// Make table view cover the entire super view.
tableView.frame = view.bounds
Data Source 프로퍼티 선언
tableView 에 띄울 Data 를 관리할 Data Source 를 작성해야 한다. 현재 작성 중인 View Controller 가 UITableViewDataSource 를 채택하도록 할 수도 있지만 View Controller 의 크기를 작게 유지하기 위해 DataSource 를 위한 Class file 을 작성한다.
PlayListDataSource 라는 이름의 Data Source 를 현재 Test 중인 View Controller 에 넘겨주면 View Controller 의 Property 인 tableView 의 dataSource 에 할당하게 하는 Test 를 작성한다.
func test_setting_DataSource() {
let dataSource = PlayListDataSource()
sut.dataSource = dataSource
sut.loadViewIfNeeded()
XCTAssertTrue(sut.tableView.dataSource === dataSource)
}
다음과 같이 Data Source Class 를 작성하고 DataSource Protocol 채택 시 작성하는 메서드의 내부를 임시로 채운다.
class PlayListDataSource: NSObject, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
UITableViewCell()
}
}
PlayListViewController 에서 dataSource Property 를 선언하고 tableView 의 dataSource 에 할당하도록 setUpTableView() 에 다음을 추가한다.
class PlayListViewController: UIViewController {
let tableView = UITableView()
var dataSource: PlayListDataSource!
...
func setUpTableView() {
...
tableView.dataSource = dataSource
}
}
Table View Data Source 작성
Data Source 를 Test 하기 위해 별도의 Test Class 를 작성한다.
이번엔 PlayListDataSource 객체가 Test 받는 System 이 된다. 이를 위해서 간단히 UITableView 를 선언해 dataSource 에 sut 를 할당하고 원하는 동작을 확인한다.
Section 의 개수 확인
첫 번째로 Section 의 개수가 1 개인지 확인한다. Table View 는 기본적으로 1 개의 Section 을 가지므로 Test 는 통과한다. 추후에 Section 의 개수를 변화시킬 때 사용되기도 하고 Test File 자체로 개발 문서로서의 기능을 유지하기 위해 당연해 보이는 Test 까지 작성한다. (고 한다...)
import XCTest
@testable import FLOMusicPlayer
class PlayListDataSourceTests: XCTestCase {
func test_has_one_section() {
let sut = PlayListDataSource()
let tableView = UITableView()
tableView.dataSource = sut
let numOfSections = tableView.numberOfSections
// The default behavior of UITableView is to have one section.
// But we will keep this test before we need another numbers of sections.
XCTAssertEqual(numOfSections, 1)
}
}
Row 의 개수 확인
다음으로 첫 번째 Section 에 원하는 만큼의 Row 가 생성되는지 확인한다. 앞으로 실제 사용할 음악 데이터를 임시로 만들고 그 개수만큼 Data Source 에서 Rows 를 만들어 주도록 한다.
임의로 만든 데이터를 Data Source 에 넘기면 해당 데이터의 개수만큼의 numberOfRows 값을 가져야 한다.
func test_numbers_of_rows_are_the_music_count() {
let music: [Music] = [
Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: ""),
]
let sut = PlayListDataSource(music: music)
let tableView = UITableView()
tableView.dataSource = sut
let numOfRows = tableView.numberOfRows(inSection: 0)
XCTAssertEqual(numOfRows, music.count)
}
Table View 의 Row 는 Section 과 달리 실제 데이터를 올려야 확인할 수 있다. 우리가 사용할 데이터를 임의로 (과제의 요구사항에 맞게) 작성했지만 여기에서 사용할 Music Class 역시 Test Driven 방식으로 작성해야 하므로 잠시 다른 Test Class 를 작성해야한다.
먼저 MusicTests Class 를 생성한다. 간단한 Test 하나를 통해 원하는 모습의 Class 를 작성할 수 있도록 한다.
class MusicTests: XCTestCase {
func test_music_class_with_required_field() {
// Given the music variable of type Music.
let music: Music
// WHEN you instantiate.
music = Music(singer: "singer", album: "album", title: "title", duration: 0, image: "image", file: "file", lyrics: "lyrics")
// THEN the properies should have the same value.
XCTAssertEqual(music.singer, "singer")
XCTAssertEqual(music.album, "album")
XCTAssertEqual(music.title, "title")
XCTAssertEqual(music.duration, 0)
XCTAssertEqual(music.image, "image")
XCTAssertEqual(music.file, "file")
XCTAssertEqual(music.lyrics, "lyrics")
}
}
Music class 를 작성해 test 를 통과한다.
class Music: Codable {
let singer: String
let album: String
let title: String
let duration: Int
let image: String
let file: String
let lyrics: String
init(singer: String, album: String, title: String, duration: Int, image: String, file: String, lyrics: String) {
self.singer = singer
self.album = album
self.title = title
self.duration = duration
self.image = image
self.file = file
self.lyrics = lyrics
}
}
다시 위의 test_numbers_of_rows_are_the_music_count() 로 돌아가서 Data Source 파일에서 Music type 의 Property 를 선언하고 초기자를 작성한다. 그리고 numberOfRowsInSection 함수에서 해당 배열의 길이를 반환하면 Test 를 통과할 수 있다.
class PlayListDataSource: NSObject, UITableViewDataSource {
private let music: [Music]
init(music: [Music]) {
self.music = music
super.init()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
music.count
}
...
Data Source 에 Property 를 선언했기 때문에 이전에 작성된 Test 가 해당 Data Source 를 사용한다면 Broken Test 가 된다. 빈 배열을 초기자에 넘기도록 이전 Test 의 PlayListDataSource 호출을 변경하고 command + U 를 눌러 모든 Test 에 이상이 없음을 확인하자.
Row 에 Data 올리기
Section 과 Row 의 개수를 확인했으므로 원하는 Data 가 Row 에 나타나는지 확인한다. 이전의 Test 와 달리 tableView.reload() 를 통해 cell 을 갱신해야 한다.
func test_show_data_for_rows_correctly() {
let music: [Music] = [
Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: "")
]
let sut = PlayListDataSource(music: music)
let tableView = UITableView()
tableView.dataSource = sut
tableView.reloadData()
let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
XCTAssertEqual("one", cell?.textLabel?.text)
}
Data Source 의 메서드에서 Row 에 맞는 배열 원소의 singer field 를 cell 의 text 로 지정한 뒤 반환한다.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.textLabel?.text = music[indexPath.row].singer
return cell
}
위 Test 에서 각 Row 마다 UITableViewCell 을 생성해 반환했지만 이는 비효율적인 방법이므로 재활용 가능한 Cell 을 사용한다. 이는 일반적인 Table View 사용 방법이므로 따로 설명은 하지 않는다.
여기에서 재활용 Cell 의 등록을 확인하기 위해 Identifier 의 값을 확인하는 방식으로 Test 를 작성한다.
이 Test 를 통해 우리가 실제 사용할 Table View 에 Reusable Cell 을 등록하는 것이기 때문에 이전 Test 와 달리 UITableView 를 생성해서 사용하지 않고 실제 사용할 View Controller 의 tableView 를 가져온다.
func test_register_reusable_cell() {
let music: [Music] = [
Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: "")
]
let sut = PlayListDataSource(music: music)
let vc = PlayListViewController()
vc.dataSource = sut
vc.loadViewIfNeeded()
let tableView = vc.tableView
tableView.reloadData()
let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
XCTAssertEqual("MusicCell", cell?.reuseIdentifier)
}
cellForRowAt indexPath 함수에서 "MusicCell" 을 식별자로 하는 재활용 Cell 을 dequeue 하게 한다.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MusicCell", for: indexPath)
cell.textLabel?.text = music[indexPath.row].singer
return cell
}
PlayListViewController 의 setUpTableView 함수에서 다음의 코드 한 줄을 추가하면 재사용 Cell 을 등록할 수 있다.
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "MusicCell")
위 Test 에서 Cell 생성 시 재사용 Cell 을 사용하게 하므로 그냥 UITableViewCell 을 사용하던 이전 Test 는 깨지게 된다. 위 register 코드를 깨지는 Test 의 tableView 에 등록하면 Test 는 통과된다.
Refatoring 후 PlayListDataSourceTests
class PlayListDataSourceTests: XCTestCase {
private var sut: PlayListDataSource!
private var music: [Music]!
override func setUpWithError() throws {
try super.setUpWithError()
music = [
Music(singer: "one", album: "", title: "", duration: 0, image: "", file: "", lyrics: "")
]
sut = PlayListDataSource(music: music)
}
override func tearDownWithError() throws {
sut = nil
music = nil
try super.tearDownWithError()
}
func setTableView() -> UITableView {
let tableView = UITableView()
tableView.dataSource = sut
return tableView
}
func test_has_one_section() {
// GIVEN the data source.
// WHEN you create table view with the given data source,
let tableView = setTableView()
// THEN there should be one section.
// The default behavior of UITableView is to have one section.
// But we will keep this test till we need another numbers of sections.
let numOfSections = tableView.numberOfSections
XCTAssertEqual(numOfSections, 1)
}
func test_numbers_of_rows_are_the_music_count() {
// GIVEN the data source.
// WHEN you create table view with given data source,
let tableView = setTableView()
// THEN there should be music.count numbers of rows.
let numOfRows = tableView.numberOfRows(inSection: 0)
XCTAssertEqual(numOfRows, music.count)
}
func test_show_data_for_rows_correctly() {
// GIVEN the data source.
// WHEN you create table view with given data source and register reusable cell,
let tableView = setTableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "MusicCell")
tableView.reloadData()
// THEN there should be correct data on the correct cell.
let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
XCTAssertEqual("one", cell?.textLabel?.text)
}
func test_register_reusable_cell() {
// GIVEN the data source.
// WHEN you create PlayListViewController instance with the data source
let vc = PlayListViewController()
vc.dataSource = sut
vc.loadViewIfNeeded()
// and get the tableView.
let tableView = vc.tableView
tableView.reloadData()
// THEN the tableView cell should have reusable cell with the identifier.
let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
XCTAssertEqual("MusicCell", cell?.reuseIdentifier)
}
}
다음 포스팅에서 음악 정보를 표현할 Custom Cell 을 작성합니다.
'iOS 개발 > App 개발 프로젝트' 카테고리의 다른 글
FLO Music App - 재생 목록 화면 (Custom Cell) (0) | 2021.10.01 |
---|---|
FLO Music App - MVC, TDD, Programmatic development (0) | 2021.09.28 |