iOS test driven development by tutorials (first edition) learn real world test driven development by joshua greene, michael katz

325 97 0
iOS test driven development by tutorials (first edition) learn real world test driven development by joshua greene, michael katz

Đang tải... (xem toàn văn)

Tài liệu hạn chế xem trước, để xem đầy đủ mời bạn chọn Tải xuống

Thông tin tài liệu

iOS TestDriven Development by Tutorials By Michael Katz Joshua Greene The book that teaches you to write maintainable and sustainable apps by building them with testing in mind or adding tests to alreadywritten apps. raywenderlich

iOS Test-Driven Development by Tutorials iOS Test-Driven Development by Tutorials By Joshua Greene & Michael Katz Copyright ©2019 Razeware LLC No7ce of Rights All rights reserved No part of this book or corresponding materials (such as text, images, or source code) may be reproduced or distributed by any means without prior written permission of the copyright owner No7ce of Liability This book and all corresponding materials (such as source code) are provided on an “as is” basis, without warranty of any kind, express of implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in action of contract, tort or otherwise, arising from, out of or in connection with the software or the use of other dealing in the software Trademarks All trademarks and registered trademarks appearing in this book are the property of their own respective owners raywenderlich.com iOS Test-Driven Development by Tutorials Dedica7ons "For my girls I love you very much." — Joshua Greene "Dedicated to the memory of my mother-in-law, Barbara Schwartz Her selflessness and dedication to teaching inspires me to give back to the community and educate others." — Michael Katz raywenderlich.com iOS Test-Driven Development by Tutorials About the Authors Joshua Greene is an author of this book He's an experienced software developer and has created many mobile apps When he's not slinging code, you can find him wandering the streets of Tokyo You can reach him on Twitter @jrg_developer Michael Katz is a champion baker ;] Oh, he's also an author of this book, developer, architect, speaker, writer and avid homebrewer He has contributed to several books on iOS development and is a long-time member of the raywenderlich.com tutorial team He's currently serving as director of mobile engineering at Viacom He shares his home state of New York with his family, the world's best bagels and the Yankees When he's not at his computer, he's out on the trails, in his shop or reading a good book (like this one!) About the Editors Darren Ferguson is the final pass editor for this book He is an experienced software developer and works for M.C Dean, Inc, a systems integration provider from North Virginia When he's not coding, you'll find him enjoying EPL Football, traveling as much as possible and spending time with his wife and daughter Manda Frederick is the editor of this book She has been involved in publishing for over ten years through various creative, educational, medical and technical print and digital publications, and is thrilled to bring her experience to the raywenderlich.com family as Managing Editor In her free time, you can find her at the climbing gym, backpacking in the backcountry, hanging with her dog, working on poems, playing guitar and exploring breweries raywenderlich.com iOS Test-Driven Development by Tutorials Jeff Rames is a tech editor for this book He’s an enterprise software developer in San Antonio, Texas who's focused on iOS for nearly a decade He spends his free time with his wife and daughters, except when he abandons them for trips to Cape Canaveral to watch rocket launches Say hi on Twitter @jefframes! James Taylor is a tech editor for this book He’s an iOS developer living in San Antonio, Texas with both his wife and daughter He enjoys bicycle touring around the United States and spending way too much time on YouTube You can find him on Twitter @jamestaylorios About the Ar7st Vicki Wenderlich is the designer and artist of the cover of this book She is Ray’s wife and business partner She is a digital artist who creates illustrations, game art and a lot of other art or design work for the tutorials and books on raywenderlich.com When she’s not making art, she loves hiking, a good glass of wine and attempting to create the perfect cheese plate raywenderlich.com iOS Test-Driven Development by Tutorials Table of Contents: Overview Introduc7on 14 What You Need 17 Book License 18 Book Source Code & Forums 20 Sec7on I: Hello, TDD! 22 Chapter 1: What Is TDD? 23 Chapter 2: The TDD Cycle 28 Sec7on II: Beginning TDD 42 Chapter 3: TDD App Setup 43 Chapter 4: Test Expressions 63 Chapter 5: Test Expecta7ons 89 Chapter 6: Dependency Injec7on & Mocks 116 Sec7on III: TDD with Networking 140 Chapter 7: Introducing Dog Patch 141 Chapter 8: Networking client 146 Chapter 9: Using the Network Client 174 Chapter 10: Image Client 193 Sec7on IV: TDD in Legacy Apps 228 Chapter 11: Legacy Problems 230 Chapter 12: Dependency Maps 254 Chapter 13: Breaking Up Dependencies 270 raywenderlich.com iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies 290 Chapter 15: Adding Features to Exis7ng Classes 307 raywenderlich.com iOS Test-Driven Development by Tutorials Table of Contents: Extended Introduc7on 14 About this book 15 Sec7on introduc7ons 15 How to read this book 16 What You Need 17 Book License 18 Book Source Code & Forums 20 Sec7on I: Hello, TDD! 22 Chapter 1: What Is TDD? 23 Why should you use TDD? What should you test? But TDD takes too long! When should you use TDD? Key points 24 25 26 26 27 Chapter 2: The TDD Cycle 28 Ge_ng started Red: Write a failing test Green: Make the test pass Refactor: Clean up your code Repeat: Do it again TDDing init(availableFunds:) TDDing addItem Adding two items Challenge Key points 29 29 31 31 32 32 35 37 40 41 Sec7on II: Beginning TDD 42 raywenderlich.com iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup 43 About the FitNess app Your first test Red-Green-Refactor Test nomenclature Structure of XCTestCase subclass Your next set of tests Using @testable import Tes7ng ini7al condi7ons Refactoring Challenge Key points Where to go from here? 43 44 48 52 53 55 56 59 60 61 62 62 Chapter 4: Test Expressions 63 Assert methods View controller tes7ng Test ordering mahers Code coverage Debugging tests Challenge Key points Where to go from here? 64 72 77 80 83 87 88 88 Chapter 5: Test Expecta7ons 89 Using an expecta7on 89 Tes7ng for true asynchronicity 92 Wai7ng for no7fica7ons 95 Showing the alert to a user 99 Ge_ng specific about no7fica7ons 105 Driving alerts from the data model 106 Using other types of expecta7ons 113 Challenge 114 raywenderlich.com iOS Test-Driven Development by Tutorials Key points 115 Where to go from here? 115 Chapter 6: Dependency Injec7on & Mocks 116 What's up with fakes, mocks, and stubs? Understanding CMPedometer Mocking Handling error condi7ons Ge_ng actual data Making a func7onal fake Wiring up the chase view Time dependencies Challenge Key points Where to go from here? 116 117 119 123 129 132 135 137 138 139 139 Sec7on III: TDD with Networking 140 Chapter 7: Introducing Dog Patch 141 Ge_ng started 141 Understanding Dog Patch's architecture 144 Where to go from here? 145 Chapter 8: Networking client 146 Ge_ng Started Se_ng up the networking client TDDing the networking call Dispatching to a response queue Key points 146 148 151 162 172 Chapter 9: Using the Network Client 174 Ge_ng started 174 Crea7ng a shared instance 175 Adding a network client property 177 raywenderlich.com 10 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Now, you have a method to send the report that you can use within the test Open AnalyticsAPITests.swift, find testAPI_whenReportSent_thenReportIsSent() and replace the when section with the following: // when sut.sendReport(report: report) The hard part is figuring out how to test that the report was sent This is a unit test, so you don’t want to rely on a live back end to verify the app logic On of top that, the test instance of API doesn’t even have a valid URL to call! What you really want is a mock object that stands in for the back end, but also uses the real API implementation If you just mock AnalyticsAPI, then the test would only verify that, when you call an object method, the method executes So you need the real API To get around this, another protocol and injection comes to the rescue! Open API.swift and add the following protocol to the file: protocol RequestSender { func send(request: URLRequest, success: ((T) -> ())?, failure: ((Error) -> ())?) } This method takes a URLRequest, sends it and reports back successes or failures in one or the other completion blocks API already has a method that basically does this, so it will be easy to implement Add the following extension to the bottom of the file: extension API: RequestSender { func send(request: URLRequest, success: ((T) -> ())?, failure: ((Error) -> ())?) where T : Decodable { let task = loadTask(request: request, success: success, failure: failure) task.resume() } } This reuses loadTask(request:success:failure:) to make URLSessionTask and to forward the success and failure blocks The method also starts the task, since it doesn’t return a value and there’s no other way to execute task raywenderlich.com 311 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Finally, add the following var to API below var token: lazy var sender: RequestSender = self This sets up a request sender that can be injected later, but uses itself as a default This will be a leverage point for adding testing to API in the next step It may seem a little indirect to have a self-reference like this However, taking this step allows you to otherwise leave this class untouched and still add new functionality to it, including testing Tes7ng the API In the MyBizTests target, create a new group: Mocks In that group, create a new file, MockSender.swift, and replace its contents with the following: import XCTest @testable import MyBiz class MockSender: RequestSender { var lastSent: Decodable? = nil func send(request: success: failure: let decoder = JSONDecoder() decoder.dateDecodingStrategy = } } URLRequest, ((T) -> ())?, ((Error) -> ())?) { iso8601 { let obj = try decoder.decode(T.self, from: request.httpBody!) lastSent = obj success?(obj) } catch { print("error decoding a \(T.self): \(error)") failure?(error) } This class implements the RequestSender protocol by returning the object that you used to create the request body then storing it in lastSent There are a lot of things you could from here, but this is sufficient to finish the test Go back to AnalyticsAPITests.swift and add a variable for the mock: var mockSender: MockSender! raywenderlich.com 312 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Next, add the following to the end of setUp() : mockSender = MockSender() sutImplementation.sender = mockSender Next, add the following to tearDown(), just before super.tearDown(): mockSender = nil Finally replace the then section of testAPI_whenReportSent_thenReportIsSent() with the following: // then XCTAssertNotNil(mockSender.lastSent) XCTAssertEqual(report.name, "name") XCTAssertEqual((mockSender.lastSent as? Report)?.name, "name") Remember that MockSender stores the sent object in lastSent, so you’re able to use this to verify the passed-in Report was sent Build and run the test and you’ll see it still fails You still need to supply the implementation for sendReport(report:) Sprou7ng the send method API already has a method that takes an object and sends it to the back end: submitPO(po:) It’s too bad that this is specifically for sending purchase orders You could refactor this method by mapping its dependencies, writing characterization and unit tests, and expanding the API functionality in a reusable way BUT, you don’t have time for that amount of refactoring right now In this case, you’re going to something your teachers told you never to do: Copy code It’s okay You’re going to have tests for this copied method, and this work is only meant to support you as you add analytics You will go back and finish the refactor after you get this working Open API+Analytics.swift and add the following API extension to the end of the file: extension API { // func logAnalytics(analytics: Report, completion: @escaping (Result) -> ()) throws { // let url = URL(string: server + "api/analytics")! var request = URLRequest(url: url) if let token = token?.token { raywenderlich.com 313 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes let bearer = "Bearer \(token)" request.addValue(bearer, forHTTPHeaderField: "Authorization") } request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" let coder = JSONEncoder() coder.dateEncodingStrategy = iso8601 let data = try coder.encode(analytics) request.httpBody = data } } // sender.send( request: request, success: { savedEvent in completion(.success(savedEvent)) }, failure: { error in completion(.failure(error)) }) This code replicates the code of submitPO(po:) with a few notable changes: logAnalytics(analytics:completion:) takes an analytics Report instead of a PurchaseOrder Also, importantly, it has a completion block which returns a Result instead of relying on the hard-to-understand, and probably buggy, delegate that came with the original app code Taking advantage of new language features and modern patterns is a good idea if you can roll them out as you improve or add code Instead of the hard-coded endpoint for purchase orders, this has a hard-coded analytics endpoint This uses the new RequestSender.send(request:success:failure:) that you introduced to API earlier This means that you’ll be able to test this method! Here, you’re using a technique called sprouting a method, which is when you add a new method in an existing class that enhances or duplicates existing functionality so you can add a new feature This technique allows you to sidestep going down a hole refactoring a class or potentially breaking things not yet under test It allows you to define a new interface, cleanly separated from the legacy part of the code In this case, the interface is even defined in a separate file raywenderlich.com 314 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes To finish up this task, connect logAnalytics to AnalyticsAPI by adding the following to sendReport(report:) in the extension: try? logAnalytics(analytics: report) { _ in } You call logAnalytics(analytics:completion:), passing the report and a blank completion and no error handling Now, build and test and the tests will pass You’ve successfully added a new (and testable!) method to API with only minimal intrusion into the existing codebase Adding analy7cs to the view controllers The hard work is over, and the rest should be easy, right? If you think back to the list of steps for analytics, you still need to implement this part: • A user-initiated action, like a screen view or button tap You’ll start with the leftmost view controller: AnnouncementsTableViewController First, create a new Swift File in MyBizTests/ Cases named AnnouncementsTableViewControllerTests.swift Finally, replace the contents of the file with the following: import XCTest @testable import MyBiz class AnnouncementsTableViewControllerTests: XCTestCase { var sut: AnnouncementsTableViewController! override func setUp() { super.setUp() sut = UIStoryboard(name: "Main", bundle: nil) instantiateViewController(withIdentifier: "announcements") as? AnnouncementsTableViewController } override func tearDown() { sut = nil super.tearDown() } func whenShown() { sut.viewWillAppear(false) } raywenderlich.com 315 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes func testController_whenShown_sendsAnalytics() { // when whenShown() } } // then the report will be sent // ??? This sets up a test where the system under test is an AnnouncementsTableViewController The purpose of testController_whenShown_sendsAnalytics() is to test that viewWillAppear(_:) will result in an analytics report being sent whenShown() triggers this step The next step is figuring out how to verify that Not mocking all of the API You’ve already set up a protocol to help out with the testing: AnalyticsAPI You don’t need to use API or mock out the RequestSender at all In the Mocks group, create a new Swift File named MockAnalyticsAPI.swift and replace its contents with the following: import XCTest @testable import MyBiz class MockAnalyticsAPI: AnalyticsAPI { var reportSent = false } func sendReport(report: Report) { reportSent = true } This class implements AnalyticsAPI, but instead of sending the report on, it uses reportSent to flag that it triggered Your previous tests on API ensure that the report will make its way to the server Back in AnnouncementsTableViewControllerTests.swift, add a new var to the class: var mockAnalytics: MockAnalyticsAPI! raywenderlich.com 316 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Next, add the following to the end of setUp(): mockAnalytics = MockAnalyticsAPI() sut.analytics = mockAnalytics This creates the new mock and sets it on the sut Next, add the following to tearDown(), just above super.tearDown(): mockAnalytics = nil Next, in testController_whenShown_sendsAnalytics() add the following to the then condition: XCTAssertTrue(mockAnalytics.reportSent) Recall that MockAnalyticsAPI sets reportSent to false on initialization, and a successful sendReport(report:) should set it to true This allows the test to verify the report will be sent Finally, to get the test to build and pass, you need to wire up viewWillAppear(_:) to the analytics API In AnnouncementsTableViewController.swift add the following below var announcements: var analytics: AnalyticsAPI? Finally, add the following to the end of viewWillAppear(_:): let screenReport = Report(name: AnalyticsEvent.announcementsShown.rawValue, recordedDate: Date(), type: AnalyticsType.screenView.rawValue, duration: nil, device: UIDevice.current.model, os: UIDevice.current.systemVersion, appVersion: Bundle.main object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String) analytics?.sendReport(report: screenReport) This creates a Report with some useful information about the app, device and the specific event You then hand it off to AnalyticsAPI, which sends it to the back end Build and test; you’re back to green raywenderlich.com 317 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Another interes7ng use case To implement the prior test, you set up a whole mock instance of AnalyticsAPI You can use this for testing, without having to worry about the messiness that was previously built into MockAPI as a subclass of API By using this protocol and starting with a mock implementation, you’ll ensure by default that any new methods you add to the app will be testable Another thing you can with mocks is to verify the number of times a method is called or the order in which methods are called Open MockAnalyticsAPI.swift, and add the following below var reportSent: var reportCount = Next, add the following to the end of sendReport(report:): reportCount = reportCount + Now, every time you call sendReport(report:), reportCount increments Next, add the following test at the end of AnnouncementsTableViewControllerTests.swift: func testController_whenShownTwice_sendsTwoReports() { // when whenShown() whenShown() } // then XCTAssertEqual(mockAnalytics.reportCount, 2) This tests that each time the screen displays, it will send a report Build and test and you should be all green Passing around dependencies The analytics feature now works in tests, but not when you run the app That’s because you still need to pass AnalyticsAPI to the AnnouncementsTableViewController When using storyboards, you want to this in a prepare(for:sender:) segue method to inject whatever dependencies you need into the next view controller (or, raywenderlich.com 318 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes similarly, in a view model or other helper) This app uses a plain UITabBarController that’s manually added to the screen: There’s no prepare(for:sender:) method to override Therefore, you have to set analytics manually, too You know that you’re potentially going to add it to many view controllers It makes sense to think about a way that you can add it to existing classes with minimal impact That means protocols to the rescue, once again Open AnalyticsAPI.swift and add the following protocol to the end of the file: protocol ReportSending: AnyObject { var analytics: AnalyticsAPI? {get set} } By adding a var and adhering to this protocol in any class, you’ll be able to inject an AnalyticsAPI implementation Open AnnouncementsTableViewController.swift and add the following extension to the end of the file: extension AnnouncementsTableViewController: ReportSending {} And, just like that, you can provide AnnouncementsTableViewController an analytics object without exposing any additional information about itself Open AppDelegate.swift, replace the contents of handleLogin(userId:) with the following: self.userId = userId let storyboard = UIStoryboard(name: "Main", bundle: nil) let tabController = storyboard.instantiateViewController( withIdentifier: "tabController") as! UITabBarController tabController.viewControllers? compactMap { $0 as? ReportSending } forEach { $0.analytics = api } window?.rootViewController = tabController This now adds an AnalyticsAPI to all of the tab bar’s view controllers that adhere to ReportSending Because that includes AnnouncementsTableViewController, you’ll now see logging whenever its viewWillAppear(_:) fires raywenderlich.com 319 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Build and run the app After logging in, the AnnouncementsTableViewController tab will display Open http://localhost:8080/api/analytics in a browser and you’ll see recorded events similar to those below: Adding more events So you now have one screen sending reports It should be straightforward to add reports to additional screens For example, in OrgTableViewController.swift add the following var: var analytics: AnalyticsAPI? Finally, add the following extension to the end of the file: extension OrgTableViewController: ReportSending {} To implement ReportSending on this controller, start with a test Create a new Swift File named OrgTableViewControllerTests.swift Open MyBizTests\Cases and replace the contents with the following: import XCTest @testable import MyBiz class OrgTableViewControllerTests: XCTestCase { var sut: OrgTableViewController! var mockAnalytics: MockAnalyticsAPI! override func setUp() { super.setUp() sut = UIStoryboard(name: "Main", bundle: nil) instantiateViewController(withIdentifier: "org") as? OrgTableViewController raywenderlich.com 320 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes } mockAnalytics = MockAnalyticsAPI() sut.analytics = mockAnalytics override func tearDown() { sut = nil mockAnalytics = nil super.tearDown() } func whenShown() { sut.viewWillAppear(false) } func testController_whenShown_sendsAnalytics() { // when whenShown() } } // then XCTAssertTrue(mockAnalytics.reportSent) This should look familiar, as it’s very similar to AnnouncementsTableViewControllerTests testController_whenShown_sendsAnalytics() tests that a report is sent when OrgTableViewController displays To get the test to pass, OrgTableViewController will need to send the report when its view displays But, before modifying viewWillAppear(_:), it would be a good idea to create a helper method so you don’t have to copy over the boilerplate Open Report.swift and add the following method to Report: static func make( event: AnalyticsEvent, type: AnalyticsType) -> Report { } return Report(name: event.rawValue, recordedDate: Date(), type: type.rawValue, duration: nil, device: UIDevice.current.model, os: UIDevice.current.systemVersion, appVersion: Bundle.main object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String) raywenderlich.com 321 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes This factory method takes care of all the constants that go into a report, so the caller only has to worry about the specifics on each screen You should be comfortable enough with TDD at this point to write a test for it on your own (Check out ReportTests.swift in the final project if you want a hint) You can now use this method in OrgTableViewController.swift Add the following to the end of viewWillAppear(_:): let report = Report.make(event: orgChartShown, type: screenView) analytics?.sendReport(report: report) Now, the tests will pass Build and run, and you should see two different screen events recorded as you change tabs Congrats, you’ve managed to add a new feature to a reasonably-complicated app You’ve done so with minimal changes to the existing code and you’ve written tests along the way raywenderlich.com 322 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes Challenge There are few tasks left undone that you should take care of: • Clean up the AnnouncementsTableViewController to use the Report.make method • Add screenView analytics to the other screens As a hint, you’ll have to forward the AnalyticsAPI through UINavigationControllers Key points • You don’t have to bring a whole class under test to add new functionality • You can sprout a method to extend functionality, even if it adds a little redundancy • Use protocols to inject dependencies and extensions to separate new code from legacy code • TDD methods will guide the way for clean and tested features Where to go from here? Although you’ve come a long way, you’ve just scratched the surface of making changes and improving code You can continue to decompose API into specific protocols like AnalyticsAPI and LoginAPI You can also now incrementally improve API by replacing delegates with Results and using the RequestSender to make the code more testable You can also rework RequestSender into its own object to pass into API that contains the server details Then you could replace MockAPI in the existing tests so you can write better and more comprehensive unit tests This eliminates the need for characterization tests to contact a live sever altogether Your work is never done This approach also has some downsides The indirection introduced by lots of small protocols can make the code harder to debug, which is why having comprehensive tests is crucial When sprouting methods, it can be tempting to never to go back and revisit your old code, leaving the app in a state that might be confusing for newcomers It also means the legacy code never improves raywenderlich.com 323 iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes This is the end of the legacy code tutorials Check out Design Patterns by Tutorials https://store.raywenderlich.com/products/design-patterns-by-tutorials for more techniques for reorganizing and isolating code raywenderlich.com 324 .. .iOS Test-Driven Development by Tutorials iOS Test-Driven Development by Tutorials By Joshua Greene & Michael Katz Copyright ©2019 Razeware LLC No7ce of... License By purchasing iOS Test-Driven Development by Tutorials, you have the following license: • You are allowed to use and/or modify the source code in iOS Test-Driven Development by Tutorials. .. raywenderlich.com iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies 290 Chapter 15: Adding Features to Exis7ng Classes 307 raywenderlich.com iOS Test-Driven Development by Tutorials

Ngày đăng: 17/05/2021, 07:53

Từ khóa liên quan

Mục lục

  • Introduction

    • About this book

    • Section introductions

    • How to read this book

    • What You Need

    • Book License

    • Book Source Code & Forums

    • Chapter 1: What Is TDD?

      • Why should you use TDD?

      • What should you test?

      • But TDD takes too long!

      • When should you use TDD?

      • Key points

      • Chapter 2: The TDD Cycle

        • Getting started

        • Red: Write a failing test

        • Green: Make the test pass

        • Refactor: Clean up your code

        • Repeat: Do it again

        • TDDing init(availableFunds:)

        • TDDing addItem

        • Adding two items

        • Challenge

Tài liệu cùng người dùng

Tài liệu liên quan