본문 바로가기

iOS 개발/App 개발 프로젝트

FLO Music App - 재생 목록 화면 (Table View)

trikalabs / TDD UITableView

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 을 작성합니다.