iOS 单元测试和界面测试教程

发表于:2017-12-14 11:28

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:默默熊    来源:51Testing软件测试网采编

  使用 XCTestExpectation 测试异步操作
  <p>现在,你已经学会了如何测试模型和调试测试失败的情况。下面让我们继续使用XCTestExpectation来测试网络操作。</p><p>
  打开HalfTunes工程:它利用URLSession来调用iTunes API查询和下载歌曲的试听片段。假设你想修改它,改用AlamoFire来进行网络操作。要查看改变是否会引入任何问题,您应该为网络操作编写测试方法并在该换为AlamoFire的前后运行它们。</p><p>
  URLSession方法是异步调用:它们会立刻返回,但真正结束运行还要等上一段时间。为了测试异步方法,使用XCTestExpectation方法使你的测试等待异步操作完成。</p>
  <p>异步测试通常是比较花时间的,所以应该将它们与那些可以很快就执行完的测试方法分开。</p>
  <p>从+菜单选择"新建单元测试目标…",将测试命名为HalfTunesSlowTests。在import声明后导入HalfTunes:</p>
  <pre><code>@testable import HalfTunes</code></pre>
  <p>在这个类的测试中将使用默认会话来将请求发送给苹果的服务器,所以声明一个SUT对象,并在setup()方法中创建它,在teardown()方法中释放它:</p>
  <pre>var sessionUnderTest: URLSession! override func setUp() { super.setUp() sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default) } override func tearDown() { sessionUnderTest = nil super.tearDown() }</pre>用下面的异步测试方法替换<code>testExample()</code>:
  <pre>// Asynchronous test: success fast, failure slow func testValidCallToiTunesGetsHTTPStatusCode200() { // given let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") // 1 let promise = expectation(description: "Status code: 200") // when let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in // then if let error = error { XCTFail("Error: \(error.localizedDescription)") return } else if let statusCode = (response as? HTTPURLResponse)?.statusCode { if statusCode == 200 { // 2 promise.fulfill() } else { XCTFail("Status code: \(statusCode)") } } } dataTask.resume() // 3 waitForExpectations(timeout: 5, handler: nil) }</pre><p>这个测试方法发送一个有效的查询给iTunes并期望返回的状态代码是200。这里的大多数代码和在应用程序中编写的代码相同,除了下面这些额外的代码行:</p>
  <ol><li>expectiation(_:) 返回一个XCTestExpectation对象,它被赋值给promise变量。该对象的其他常用名称是expectation和future。参数description描述了期望发生的结果。
  <li>为了和描述匹配,在异步方法完成处理函数的成功条件分支中调用promise.fulfill()。
  <li>waitForExpectations(_: handler:)保证测试在所有的期望被达成前保持运行,或者直到超时结束,以二者先发生者为准。</ol>
  <p>执行测试。如果你连接到了互联网,在模拟器启动后,测试应该需要大约一秒钟成功返回。(译注:这个方法用URLSession执行了和程序中类似的操作,但没有直接测试程序中的代码)</p>
  <h3>让失败发生得更快</h3>
  <p>失败是有害的,但不必总保持失败。在这里将讨论如果测试失败了,如何快速找出原因。把时间节省下来可以更好地浪费在脸谱网上。:]</p>
  <p>要修改测试,以使异步操作返回失败结果,只需要从网址中的“iTunes”里删除“s”
  <pre><code>leturl=URL(string:"https://itune.apple.com/search?media=music&entity=song&term=abba")</code></pre>
  执行测试:测试会返回失败,但它需要等待整个超时时间间隔!这是因为它的期望是请求会成功,就是在调用promise.fulfill()的地方。由于请求失败,测试只有在超时过期了才结束。(译注:虽然测试已经失败了(XCTFail()被调用),但因为promise.fulfill()没有被调用,方法不会立刻结束。)
  <p>通过更改期望,可以使测试失败情况发生的更快,而不必等待请求成功。只要等到异步方法的完成处理方法被调用就可以了。也就是当应用程序收到来自服务器的响应(OK或错误)时。这满足了期望,然后测试里可以接着检查请求是否成功。</p>
  <p>要查看这是如何工作的,你将创建一个新的测试。首先,撤销上面对URL的更改来修复测试,然后在类中添加下面的测试方法:</p>
  <pre>```// Asynchronous test: faster fail
  func testCallToiTunesCompletes() {
  // given
  let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
  // 1
  let promise = expectation(description: "Completion handler invoked")
  var statusCode: Int?
  var responseError: Error?
  // when
  let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
  statusCode = (response as? HTTPURLResponse)?.statusCode
  responseError = error
  // 2
  promise.fulfill()
  }
  dataTask.resume()
  // 3
  waitForExpectations(timeout: 5, handler: nil)
  // then
  XCTAssertNil(responseError)
  XCTAssertEqual(statusCode, 200)
  }</pre> 这里的关键是,单单进入完成处理程序就满足了期望,这需要大约一秒钟。如果请求失败了,则then部分的断言会失败。 <p>执行测试:它现在需要大约一秒钟就会失败返回。失败是因为查询请求失败了,而不是因为测试执行超时。 修复url```,然后再次执行测试并确认现在结果是成功的。</p>
  伪对象和交互
  <p>异步测试是为了确认代码中调用异步API的输入参数是正确的。你也可能还想要测试接收urlsession的返回值的代码也能正常工作,或者程序可以正确地更新UserDefaults或CloudKit数据库。</p><p>
  大多数应用都要同系统或者库函数对象打交道,这些对象不受你控制。如果测试方法同这些对象进行交互,那么执行起来可能会很慢或者结果不具有可重复性。这就违反了FIRST原则中的两个,执行的速度要快和具有可重复性。使用输入桩(stubs)或通过更新模拟对象(mock objects)来伪造交互是常用的替代方法。</p>
  当你的代码依赖于某个系统或库时,可以使用伪装,即创建一个伪对象来扮演相关的系统或库,并把它注入到你的代码中。Jon Reid写的依赖注入描述了几种可以达到这个目的的方法。
  </p>
  来自桩(stub)的伪输入
  <p>在这个测试中,你会通过检查searchResults.count的值来判断程序的<code>updateSearchResults(_:)</code>方法是否正确地解析了会话所下载的数据。在这里,SUT是视图控制器,你会用桩和一些预先下载的数据来伪造会话。</p><p>
  从+菜单选择“新的单元测试目标…”。把它命名为HalfTunesFakeTests。在import语句下方导入HalfTunes :</p>
  <pre><code>@testable import HalfTunes</p></code></pre><p>声明SUT,在setup()中创建它,并在teardown()中释放:</p><pre>var controllerUnderTest: SearchViewController! override func setUp() { super.setUp() controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! SearchViewController! } override func tearDown() { controllerUnderTest = nil super.tearDown() }</pre>
  <pre>注:这里SUT是视图控制器。因为HalfTunes有视图控制器过于庞大的问题-所有的工作都在SearchViewController.swift中进行。将网络代码移动到单独的模块可以减轻这个问题,也会使测试变得更容易。</pre><p>
  接下来,你将需要产生一些JSON样本数据来由伪会话返回给你的测试方法。数据有几条就够了,所以在发给iTunes的URL字符串后面加上“& limit=3”来限制返回结果:</p>
  <p>https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3</p>
  <p>拷贝粘贴这个URL到浏览器中。这会下载1.txt或一个类似的文件。预览确认这是一个JSON文件,然后将它改名为abbaData.json并添加到HalfTunesFakeTests组中。</p>
  <p>HalfTunes工程中包含支持文件DHURLSessionMock.swift。其中定义了一个简单的协议——DHURLSession。这个协议中包含两个使用URL或URLRequest来创建数据任务的方法(stubs)。它还定义了一个实现了该协议的URLSessionMock。URLSessionMock提供一个构造器,它可以根据你提供的数据(data, response, error)创建一个模拟URLSession对象。</p>
  <p>如下所示,在setup()中创建SUT后,建立伪数据和响应,并建立伪会话对象。</p>
  <pre>let testBundle = Bundle(for: type(of: self)) let path = testBundle.path(forResource: "abbaData", ofType: "json") let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped) let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") let urlResponse = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: nil, headerFields: nil) let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)</pre><p>在setup()的结尾,将伪会话作为SUT的属性注入到应用程序中:</p>
  <pre>controllerUnderTest.defaultSession = sessionMock</pre>
  <pre>注:你将在你的测试中直接使用伪会话,但向你展示了如何进行注入,以便在未来的测试中可以调用SUT的方法来使用视图控制器的defaultSession属性。</pre><p>现在你准备好了写一个测试来检查对<code>updateSearchResults(_:)</code>的调用是否正确解析了所提供的伪数据。用下面的代码替换<code>testExample()</code>:</p>
  <pre>// Fake URLSession with DHURLSession protocol and stubs func test_UpdateSearchResults_ParsesData() { // given let promise = expectation(description: "Status code: 200") // when XCTAssertEqual(controllerUnderTest?.searchResults.count, 0, "searchResults should be empty before the data task runs") let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba") let dataTask = controllerUnderTest?.defaultSession.dataTask(with: url!) { data, response, error in // if HTTP request is successful, call updateSearchResults(\_:) which parses the response data into Tracks if let error = error { print(error.localizedDescription) } else if let httpResponse = response as? HTTPURLResponse { if httpResponse.statusCode == 200 { promise.fulfill() self.controllerUnderTest?.updateSearchResults(data) } } } dataTask?.resume() waitForExpectations(timeout: 5, handler: nil) // then XCTAssertEqual(controllerUnderTest?.searchResults.count, 3, "Didn't parse 3 items from fake response") }</pre>
  <p>因为桩提供的是一个异步方法,这个测试也仍然必须写成异步的。(译注:这里的桩是sessionMock)</p>
  <p>when断言的条件是searchResults在数据任务运行前为空-这应该是真的,因为你在setup()中创造了一个全新的SUT。</p>
  <p>伪数据包含了三个音轨对象的JSON数据,所以then断言的条件是,视图控制器的搜索结果数组包含三个项目。</p>
  <p>执行测试。它应该很快就返回成功,因为没有任何真正的网络连接。</p>
  对模拟对象的假更新
  <p>以前的测试使用存根从伪对象提供输入。接下来,你可以使用一个模拟对象来测试你的代码可以正确地更新UserDefaults。</p>
  <p>重新打开BullsEye项目。该应用程序有两种玩法:用户要么移动滑块来匹配目标值或根据滑块位置猜测目标值。右下角的分段控件可以切换游戏玩法和更新gameStyle用户默认值以保持一致。</p>
  <p>你的下一个测试将检查应用程序正确地更新了gameStyle的默认值。</p>
  <p>在测试导航栏,点击“新的单元测试的目标”,将测试命名为BllsEyeMockTests。在import语句下面添加以下内容:</p>
  <pre>@testable import BullsEye class MockUserDefaults: UserDefaults { var gameStyleChanged = 0 override func set(_ value: Int, forKey defaultName: String) { if defaultName == "gameStyle" { gameStyleChanged += 1 } } }</pre>
  MockUserDefaults覆盖了set(_:forKey:)方法来增大gameStyleChanged标志。你经常会看到类似的测试中设置的是布尔变量,但使用递增整数可以给你更多的灵活性,例如,您的测试可以检查方法是否正好被调用了一次。
  在BullsEyeMockTests中声明SUT和mock对象:
  <pre>var controllerUnderTest: ViewController! var mockUserDefaults: MockUserDefaults!</pre>
  在setup()中创建SUT和模拟对象,然后注入模拟对象作为SUT的属性:
  <pre>controllerUnderTest = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as! ViewController! mockUserDefaults = MockUserDefaults(suiteName: "testing")! controllerUnderTest.defaults = mockUserDefaults</pre>
  释放SUT和teardown()中的模拟对象:
  <pre>controllerundertest = nil mockuserdefaults = nil</pre>
  将testexample()替换为:
  <pre>// Mock to test interaction with UserDefaults func testGameStyleCanBeChanged() { // given let segmentedControl = UISegmentedControl() // when XCTAssertEqual(mockUserDefaults.gameStyleChanged, 0, "gameStyleChanged should be 0 before sendActions") segmentedControl.addTarget(controllerUnderTest, action: #selector(ViewController.chooseGameStyle(_:)), for: .valueChanged) segmentedControl.sendActions(for: .valueChanged) // then XCTAssertEqual(mockUserDefaults.gameStyleChanged, 1, "gameStyle user default wasn't changed") }</pre>
  <p>when断言的条件是在测试方法taps分段控件前gamestylechanged标志为0。所以如果then断言也为真,意味着<code>set(_:forKey:)</code>正好被调用了一次。运行测试,结果应该为成功。</p>
  在Xcode中测试用户交互(UI)
  <p>在Xcode 7中引入了UI测试,它能让你通过记录你的UI操作来创建UI测试。UI测试通过查找一个应用程序的UI对象进行查询,合成事件,然后将它们发送到这些对象。API使您能够检查UI对象的属性和状态,以便将它们与预期状态进行比较。<p>
  打开BullEyes项目,在该项目的测试导航,添加一个新的UI测试目标,然后接受默认名称BullsEyeUITests。
  在BullsEyeUITests类顶部添加属性:
  <pre><code>var app: XCUIApplication!</code></pre><p>在setup()中将语句<pre><code>XCUIApplication().launch()</code></pre>替换为:</p>
  <pre>app = XCUIApplication() app.launch()</pre></p><p>将<code>testExample()</code> 改名为<code>testGameStyleSwitch()</code>。
  在<code> testGameStyleSwitch()</code>方法中开始一个新行,然后单击编辑器窗口下方的红色录音按钮:</p>
  当应用在模拟器中启动后,点击游戏风格切换开关和对应的顶部标签。然后点击Xcode记录按钮停止录音。
  在<code>testGameStyleSwitch()</code>方法中会新生成如下三行:
  <pre>```let app = XCUIApplication()
  app.buttons["Slide"].tap()
  app.staticTexts["Get as close as you can to: "].tap()
  如果有其他内容,删除它们。第一行重复了你在<code>setup()</code>中创建的属性,你现在也不需要点击任何东西,所以删除第一行以及第二行和第三行结尾处的<code>.tap()</code>。在代码中打开<code>["Slide"]</code>右侧的小下拉菜单,然后选择<code>segmentedControls.buttons["Slide"]</code>
  现在代码的样子会变为:
  <pre>```app.segmentedControls.buttons["Slide"]
  app.staticTexts["Get as close as you can to: "]```</pre>
  修改它以创建given部分:
  <pre>```// given
  let slideButton = app.segmentedControls.buttons["Slide"]
  let typeButton = app.segmentedControls.buttons["Type"]
  let slideLabel = app.staticTexts["Get as close as you can to: "]
  let typeLabel = app.staticTexts["Guess where the slider is: "]```</pre>现在您等到了两个按钮的名称和两个可能的顶级标签。接着请添加以下内容:
  <pre>```// then
  if slideButton.isSelected {
     XCTAssertTrue(slideLabel.exists)
     XCTAssertFalse(typeLabel.exists)
     typeButton.tap()
     XCTAssertTrue(typeLabel.exists)
     XCTAssertFalse(slideLabel.exists)
  } else if typeButton.isSelected {
     XCTAssertTrue(typeLabel.exists)
     XCTAssertFalse(slideLabel.exists)
     slideButton.tap()
     XCTAssertTrue(slideLabel.exists)
     XCTAssertFalse(typeLabel.exists)
  }```</pre>这段代码检查每个按钮被选中时对应的正确的标签是否存在。执行测试-所有的断言应该显示成功。</pre></p>
  ##性能测试
  根据[苹果的文档](https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html#//apple_ref/doc/uid/TP40014132-CH4-SW8):性能测试会先取得你要评估的代码块,然后将它运行十次。收集均执行时间的平均值和标准差。这些单独测量值的平均值形成的结果可以与基准进行比较,以评估测试成功或失败。
  <p>写一个性能测试很简单:只需把你想测量到代码放入<code>measure()</code>方法中的闭包中。</p>让我们实际做一下。再次打开HalfTunes项目,在HalfTunesFakeTests中将<code> testPerformanceExample ()</code>替换为:
  <pre>```// Performance
  func test_StartDownload_Performance() {
     let track = Track(name: "Waterloo", artist: "ABBA",
     previewUrl: "http://a821.phobos.apple.com/us/r30/Music/d7/ba/ce/mzm.vsyjlsff.aac.p.m4a")
     measure {
        self.controllerUnderTest?.startDownload(track)
     }
  }```</pre>运行测试,然后如下图所示,单击```measure()```方法结尾处的图标来查看统计结果。
  ![](http://upload-images.jianshu.io/upload_images/4293407-ea824b2e04f1fafe.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  单击“设置基线”,然后再次运行性能测试并查看结果-它可能比基线更好也可能更差。编辑按钮让您可以使用这个新的结果来重置基线。
  <p>基线是根据每个设备的配置来存储的,所以你可以将相同的测试在不同的设备上执行,并根据不同的处理器,内存等配置来设定不同的基线。</p>
  <p>任何时候做可能会影响正在测试的方法的性能的更改,都请再次运行性能测试,来查看与基线比较,性能的变化。</p>
  <h2>代码覆盖</h2><p>代码覆盖工具告诉你哪些应用程序的代码真正被你的测试执行了,这样你就可以知道程序代码的哪些部分还没有被测到。</p>
  <pre>注:在启用代码覆盖时,是否应该运行性能测试?[苹果的文档](https://developer.apple.com/library/prerelease/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/07-code_coverage.html#//apple_ref/doc/uid/TP40014132-CH15-SW1)说:代码覆盖率数据收集会带来性能上的损失…以线性方式影响代码执行,这样当它启用时,性能测试结果与测试运行到测试运行保持一致。但是,您应该考虑是否在您在测试中严格评估例程的性能时启用代码覆盖率。</pre>
  为了启用代码覆盖、编辑方案(scheme)中的“测试动作”的并选中代码覆盖项:(译注:使用 ?+<  开始编辑方案) 
  ![](http://upload-images.jianshu.io/upload_images/4293407-d8b61750f83da482.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  运行所有的测试(?+U),然后打开报告导航栏(?+8)。按时间选择,选择该列表中的第一项,然后选择“覆盖”选项卡:
  ![](http://upload-images.jianshu.io/upload_images/4293407-a1fa7d574eae648c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  <p>单击三角形标志可以看到SearchViewController.swift中的函数列表:</p>
  ![](http://upload-images.jianshu.io/upload_images/4293407-2dfc295ce4dc167b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  将鼠标移动到<code>updateSearchResults(_:)</code>后面的蓝条上可以看到覆盖率为71.88%。
  <p>单击此功能的箭头按钮来打开源文件,然后定位该函数。当鼠标越过右侧边栏的覆盖注释时,代码段会高亮显示绿色或红色:</p>
  ![](http://upload-images.jianshu.io/upload_images/4293407-6bcd8a4527613815.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  覆盖注释显示多少次试验打每个代码段;没有被自行的部分被用红色标出。如你所期望的,```for```循环跑了3次,但出错处理路径上的代码都没有被执行。要增对这些功能的覆盖,你可以复制abbadata.json,然后编辑出不同的错误,例如,将"results"改为“result”,这样可以得到一个能够覆盖<code>print("Results key not found in dictionary")</code>的测试。</p>
  ###100%覆盖?
  <p>应该努力争取达到100%的代码覆盖率么?百度一下“100%单元测试覆盖率”,你会发现一系列的赞成和反对的理由,和对“100%覆盖”的定义的争论。反对方说最后10-15%是不值得努力的。支持方争论说最后的10-15%是最重要的,*因为*它难以测试。百度“hard to unit test bad design"去查找一篇很有说服力的文章[无法验证的代码是一个更深层次的设计问题的标志](https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters)。进一步思考很可能会得出这样的结论:正确的方向是[测试驱动开发](http://qualitycoding.org/tdd-sample-archives/)。</p>
  ##下一步做什么?
  <p>现在你已经掌握了一些用于编写项目测试的优秀工具。我希望这个iOS单元测试和UI测试教程给了你信心去测试所有的东西!
  您可以在[zip文件](https://koenig-media.raywenderlich.com/uploads/2016/12/Finished-3.zip)中找到已完成的项目。下面是一些深入研究的资源:</p>
  <li>现在你已经会为你的项目写测试了。下一步是自动化:持续集成和持续交付。阅读苹果的使用Xcode服务器和xcodebuild[自动化测试过程](https://developer.apple.com/library/content/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/08-automation.html#//apple_ref/doc/uid/TP40014132-CH7-SW1),和来自维基百科的[持续交付文章](https://en.wikipedia.org/wiki/Continuous_delivery),这些文章借鉴了[ThoughtWorks](https://www.thoughtworks.com/continuous-delivery)的专家的专业知识。</p>
  <li>[在Swift Playgounds中使用TDD](http://initwithstyle.net/2015/11/tdd-in-swift-playgrounds/) 介绍了在Playgounds中使用```XCTestObservationCenter```执行```XCTestCase```单元测试。你可以在Playgounds中开发你的项目代码和编写测试,然后再把两者转移到你的应用中去。
  <li>[手表应用:我们如何测试它们?](https://realm.io/news/cmduconf-boris-bugling-how-test-watch-apps/)来自[CMD + U](http://www.cmduconf.com/)会议展示了如何使用[PivotalCoreKit](https://github.com/pivotal/PivotalCoreKit)测试WatchOS应用。
  <li>如果你已经有一个应用程序,但还没有为它写过测试,你可能要参考米迦勒的[如何高效地工作在老旧代码上](https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052/ref=sr_1_1?s=books&ie=UTF8&qid=1481511568&sr=1-1),因为没有测试程序的代码就*是*老旧代码!</p><p>
  <li>乔恩瑞德的高质量编码示例应用程序文档是一个学习更多的关于[测试驱动开发](http://qualitycoding.org/tdd-sample-archives/)的好地方。
  </p><p>如果您对本教程有任何问题或意见,请加入下面的论坛讨论。:]</p>
  ###团队
  www.raywenderlich.com的每个教程都是由我们的专职团队完成,以确保其符合我们的高质量标准。创建本教程的团队成员是:
  ![](http://upload-images.jianshu.io/upload_images/4293407-47316734f86e712f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
  <b>来自译者:</b>如果你认真读到了这里,并按教程完成了里面的示例,我相信你一定和我一样收获良多。测试对我而言一直是深觉重要又觉得无从下手的一个课题,这篇文章虽然没有也无法给出所有我们想要的答案,但无疑打开了一扇大门。<p>如有任何和本文或iOS测试相关的问题,欢迎留言,谢谢。</p>


上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。
22/2<12
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号