使用量角器在Angular中进行端到端测试的入门

本文详述了如何使用Protractor为Angular应用进行端到端测试。从为何使用Protractor、配置和运行测试,到页面对象、茉莉花语法和实践练习,覆盖了Protractor的基本用法和最佳实践,旨在帮助开发者掌握端到端测试的技能。
摘要由CSDN通过智能技术生成
最终产品图片
您将要创造的

Protractor是一种流行的端到端测试框架,它使您可以在真实的浏览器中测试Angular应用程序,从而模拟浏览器的交互,就像真实用户与之交互一样。 端到端测试旨在确保应用程序的行为符合用户的预期。 而且,测试不关心实际的代码实现。

量角器在流行的Selenium WebDriver之上运行,后者是用于浏览器自动化和测试的API。 除了Selenium WebDriver提供的功能外,Protractor还提供了用于捕获Angular应用程序的UI组件的定位器和方法。

在本教程中,您将了解:

  • 设置,配置和运行量角器
  • 为量角器编写基本测试
  • 页面对象以及为什么要使用它们
  • 编写测试时要考虑的准则
  • 从头到尾为应用程序编写端到端测试

这听起来不令人兴奋吗? 但是,首先是第一件事。

我需要使用量角器吗?

如果您一直在使用Angular-CLI,则可能知道默认情况下,它附带了两个用于测试的框架。 他们是:

  • 使用Jasmine和Karma进行单元测试
  • 使用量角器进行端到端测试

两者之间的明显区别在于,前者用于测试组件和服务的逻辑,而后者用于确保应用程序的高级功能(涉及UI元素)按预期工作。

如果您不熟悉Angular中的测试,建议您阅读“使用Jasmine进行Angular中的测试组件”系列,以更好地了解在哪里画线。

在前者的情况下,您可以利用Angular测试实用程序和Jasmine的功能来编写组件和服务的单元测试,还可以编写基本的UI测试。 但是,如果您需要从头到尾测试应用程序的前端功能,则可以使用Protractor。 量角器的API与页面对象等设计模式相结合,使编写更具可读性的测试变得更加容易。 这是使事情进展的示例。

/* 
  1. It should have a create Paste button
  2. Clicking the button should bring up a modal window
*/

it('should have a Create Paste button and modal window', () => {

    expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist");
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't exist, not yet!");
    
    addPastePage.clickCreateButton();
    
    expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now");  
});

配置量角器

如果您使用Angular-CLI生成项目,则设置Protractor很容易。 ng new创建的目录结构如下。

.
├── e2e
│   ├── app.e2e-spec.ts
│   ├── app.po.ts
│   └── tsconfig.e2e.json
├── karma.conf.js
├── package.json
├── package-lock.json
├── protractor.conf.js
├── README.md
├── src
│   ├── app
│   ├── assets
│   ├── environments
│   ├── favicon.ico
│   ├── index.html
│   ├── main.ts
│   ├── polyfills.ts
│   ├── styles.css
│   ├── test.ts
│   ├── tsconfig.app.json
│   ├── tsconfig.spec.json
│   └── typings.d.ts
├── tsconfig.json
└── tslint.json

5 directories, 19 files

Protractor创建的默认项目模板取决于两个文件来运行测试: e2e目录中的spec文件和配置文件( protractor.conf.js )。 让我们看看如何配置protractor.conf.js

/* Path: protractor.conf.ts*/

// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts

const { SpecReporter } = require('jasmine-spec-reporter');

exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './e2e/**/*.e2e-spec.ts'
  ],
  capabilities: {
    'browserName': 'chrome'
  },
  directConnect: true,
  baseUrl: 'https://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function() {}
  },
  onPrepare() {
    require('ts-node').register({
      project: 'e2e/tsconfig.e2e.json'
    });
    jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
  }
};

如果可以在Chrome网络浏览器上运行该测试,则可以保持原样,并跳过本节的其余部分。

使用Selenium独立服务器设置量角器

directConnect: true允许量角器直接连接到浏览器驱动程序。 但是,在撰写本教程时,Chrome是唯一受支持的浏览器。 如果您需要多浏览器支持或运行Chrome以外的浏览器,则必须设置Selenium独立服务器。 步骤如下。

使用npm全局安装Protractor:

npm install -g protractor

这将为webdriver-manager以及量角器安装命令行工具。 现在更新webdriver-manager以使用最新的二进制文件,然后启动Selenium独立服务器。

webdriver-manager update

webdriver-manager start

最后,将directConnect: false设置为directConnect: false并添加seleniumAddress属性,如下所示:

capabilities: {
    'browserName': 'firefox'
  },
  directConnect: false,
  baseUrl: 'http://localhost:4200/',
  seleniumAddress: 'http://localhost:4444/wd/hub',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function() {}
  },

GitHub上的配置文件提供了有关Protractor上可用配置选项的更多信息。 我将使用本教程的默认选项。

运行测试

如果您使用的是Angular-CLI,则ng e2e是您开始运行测试所需的唯一命令。 如果测试看起来很慢,那是因为Angular每次运行ng e2e时都必须编译代码。 如果您想加快速度,这就是您应该做的。 使用ng serve服务应用程序。

然后启动一个新的控制台选项卡并运行:

ng e2e -s false

测试现在应该加载得更快。

我们的目标

我们将为基本的Pastebin应用程序编写E2E测试。 从GitHub存储库克隆项目。

这两个版本,入门版(没有测试的版本)和最终版本(有测试的版本)在单独的分支上可用。 现在,克隆入门分支。 (可选)为项目提供服务并遍历代码以熟悉手头的应用程序。

让我们简要地描述我们的Pastebin应用程序。 该应用程序最初会将粘贴列表(从模拟服务器检索)粘贴到表中。 表格中的每一行都有一个“ 查看粘贴”按钮,单击该按钮会打开一个引导模式窗口。 模态窗口显示粘贴数据以及用于编辑和删除粘贴的选项。 在表格末尾,有一个“ 创建粘贴”按钮,可用于添加新的粘贴。

使用量角器Sample pastebin应用程序进行端到端测试
示例应用程序。

本教程的其余部分专用于在Angular中编写量角器测试。

量角器基础

.e2e-spec.ts结尾的spec文件将托管我们应用程序的实际测试。 我们将所有测试规范放置在e2e目录中,因为这是我们将Protractor配置为查找规范的地方。

编写量角器测试时,需要考虑两件事:

  • 茉莉花语法
  • 量角器API

茉莉花语法

使用以下代码创建一个名为test.e2e-spec.ts的新文件以开始使用。

/* Path: e2e/test.e2e-spec.ts */

import { browser, by, element } from 'protractor';

describe('Protractor Demo', () => {
 
  beforeEach(() => {
    //The code here will get executed before each it block is called  
    //browser.get('/');
  });

  it('should display the name of the application',() => {
   /*Expectations accept parameters that will be matched with the real value
   using Jasmine's matcher functions. eg. toEqual(),toContain(), toBe(), toBeTruthy() etc. */
   expect("Pastebin Application").toEqual("Pastebin Application");
   
  });
  
  it('should click the create Paste button',() => {
    //spec goes here
   
  });
});

这描述了如何使用Jasmine的语法在规范文件中组织我们的测试。 describe()beforeEach()it()是全局Jasmine函数。

Jasmine具有编写测试的出色语法,并且可以与Protractor一起使用。 如果您是Jasmine的新手,我建议您先浏览Jasmine的GitHub页面

describe块用于将测试划分为逻辑测试套件。 每个describe块(或测试套件)可以具有多个it块(或测试规范)。 实际测试在测试规格中定义。

“我为什么要这样构造测试?” 你可能会问。 测试套件可用于从逻辑上描述您的应用程序的特定功能。 例如,理想情况下,与Pastebin组件有关的所有规范都应包含在标题为Pastebin Page的描述块内。 尽管这可能会导致多余的测试,但是您的测试将更具可读性和可维护性。

describe块可以具有beforeEach()方法,该方法将在该块中的每个规范之前执行一次。 因此,如果您需要浏览器在每次测试之前导航到URL,则将导航代码放在beforeEach()是正确的做法。

接受值的Expect语句与一些匹配器函数链接在一起。 比较实际值和期望值,并返回一个布尔值,该布尔值确定测试是否失败。

量角器API

现在,让我们在上面放些肉。

/* Path: e2e/test.e2e-spec.ts */

import { browser, by, element } from 'protractor';

describe('Protractor Demo', () => {
 
  beforeEach(() => {
    browser.get('/');
  });

  it('should display the name of the application',() => {
   
    expect(element(by.css('.pastebin')).getText()).toContain('Pastebin Application');
   
  });
  
  it('create Paste button should work',() => {
   
    expect(element(by.id('source-modal')).isPresent()).toBeFalsy("The modal window shouldn't appear right now ");
    element(by.buttonText('create Paste')).click();
    expect(element(by.id('source-modal')).isPresent()).toBeTruthy('The modal window should appear now');
   
  });
});

browser.get('/')element(by.css('.pastebin')).getText()Protractor API的一部分。 让我们动起来,直接进入量角器所能提供的。

下面列出了由Protractor API导出的主要组件。

  1. browser() :您应该为所有浏览器级别的操作(例如导航,调试等browser()调用browser()
  2. element() :用于基于搜索条件或条件链在DOM中查找元素。 它返回一个ElementFinder对象,并且您可以对它们执行诸如getText()click()之类的操作。
  3. element.all() :用于查找与某些条件链匹配的元素数组。 它返回一个ElementArrayFinder对象。 也可以在ElementArrayFinder上执行可以在ElementFinder上执行的所有动作。
  4. 定位器:定位器提供了在Angular应用程序中查找元素的方法。

由于我们将经常使用定位器,因此这里是一些常用的定位器。

  • by.css('selector-name') :到目前为止,这是基于CSS选择器名称查找元素的常用定位器。
  • by.name('name-value') :找到一个具有与name属性匹配的值的元素。
  • by.buttonText('button-value') :基于内部文本定位按钮元素或按钮元素数组。

注意:在编写本教程时,by.model,by.binding和by.repeater定位器不适用于Angular 2+应用程序。 请改用 基于 CSS 的定位器。

让我们为Pastebin应用程序编写更多测试。

it('should accept and save input values', () => {
      element(by.buttonText('create Paste')).click();

      //send input values to the form using sendKeys
     
      element(by.name('title')).sendKeys('Hello world in Ruby');
      element(by.name('language')).element(by.cssContainingText('option', 'Ruby')).click();
      element(by.name('paste')).sendKeys("puts 'Hello world';");

      element(by.buttonText('Save')).click();

      //expect the table to contain the new paste
      const lastRow = element.all(by.tagName('tr')).last();
      expect(lastRow.getText()).toContain("Hello world in Ruby");
});

上面的代码有效,您可以自己验证。 但是,如果在spec文件中没有针对量角器的词汇,您会不会更轻松地编写测试? 这就是我在说的:

it('should have an Create Paste button and modal window', () => {

    expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist");
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't appear, not yet!");
    
    addPastePage.clickCreateButton();
    
    expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now");
   

  });
  
 it('should accept and save input values', () => {
   
    addPastePage.clickCreateButton();
     
    //Input field should be empty initially
    const emptyInputValues = ["","",""];
    expect(addPastePage.getInputPasteValues()).toEqual(emptyInputValues);
    
    //Now update the input fields
    addPastePage.addNewPaste();
    
    addPastePage.clickSaveButton();
 
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone");
    expect(mainPage.getLastRowData()).toContain("Hello World in Ruby");

  });

没有多余的量角器包,这些规格看起来更加简单。 我是怎么做到的? 让我向您介绍页面对象。

页面对象

Page Object是一种设计模式,在测试自动化界很流行。 页面对象使用面向对象的类为应用程序的页面或部分建模。 可以将所有对象(与我们的测试相关),例如文本,标题,表格,按钮和链接都捕获在页面对象中。 然后,我们可以将这些页面对象导入规范文件并调用其方法。 这减少了代码重复,并使代码维护更加容易。

创建一个名为page-objects的目录,并在其中添加一个名为pastebin.po.ts的新文件。 与Pastebin组件有关的所有对象都将在此处捕获。 如前所述,我们将整个应用程序分为三个不同的组件,每个组件都有一个专用于它的页面对象。 .po.ts的命名方式纯属常规,您可以根据需要命名。

这是 我们正在测试的页面

Pastebin组件的角度蓝图中的端到端测试

这是代码。

pastebin.po.ts
/* Path e2e/page-objects/pastebin.po.ts*/

import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor';


export class Pastebin extends Base {
    
    navigateToHome():promise.Promise<any> {
        return browser.get('/');
  	}
    
	getPastebin():ElementFinder {
		return element(by.css('.pastebin'));
	}

	/* Pastebin Heading */
	getPastebinHeading(): promise.Promise<string> {
		return this.getPastebin().element(by.css("h2")).getText();
	}

	/*Table Data */

	getTable():ElementFinder {
		return this.getTable().element(by.css('table'));

	}

	getTableHeader(): promise.Promise<string> {
		return this.getPastebin().all(by.tagName('tr')).get(0).getText();
	}

	getTableRow(): ElementArrayFinder {
		return this.getPastebin().all(by.tagName('tr'));
	}

	
	getFirstRowData(): promise.Promise<string> {
		return this.getTableRow().get(1).getText();
	}

	getLastRowData(): promise.Promise<string> {
		return this.getTableRow().last().getText();
	}

	/*app-add-paste tag*/

	getAddPasteTag(): ElementFinder {
		return this.getPastebin().element(by.tagName('app-add-paste'));
	}

	isAddPasteTagPresent(): promise.Promise<boolean> {
		return this.getAddPasteTag().isPresent();
	}

}

让我们回顾一下到目前为止所学到的知识。 量角器的API返回对象,到目前为止,我们已经遇到了三种类型的对象。 他们是:

  • 承诺
  • 元素查找器
  • ElementArrayFinder

简而言之, element()返回一个ElementFinder,而element().all返回一个ElementArrayFinder。 您可以使用定位符( by.cssby.tagName等)在DOM中查找元素的位置,并将其传递给element()element.all()

然后,可以将ElementFinder和ElementArrayFinder与操作链接起来,例如isPresent()getText()click()等。这些方法返回一个承诺,该承诺将在完成特定动作后得到解决。

我们的测试中没有一串then()的原因是因为Protractor在内部对其进行处理。 测试似乎是同步的,即使它们不是同步的。 因此,最终结果是线性编码体验。 但是,我建议使用async / await语法,以确保代码可以将来使用。

您可以链接多个ElementFinder对象,如下所示。 如果DOM具有多个相同名称的选择器,而我们需要捕获正确的选择器,则这特别有用。

getTable():ElementFinder {
        return this.getPastebin().element(by.css('table'));

	}

现在我们已经准备好了页面对象的代码,让我们将其导入到我们的规范中。 这是我们初始测试的代码。

/* Path: e2e/mainPage.e2e-spec.ts */

import { Pastebin } from './page-objects/pastebin.po';
import { browser, protractor } from 'protractor';


/* Scenarios to be Tested 
  1. Pastebin Page should display a heading with text Pastebin Application 
  2. It should have a table header
  3. The table should have rows
  4. app-add-paste tag should exist
*/

describe('Pastebin Page', () => {
 
  const mainPage: Pastebin = new Pastebin();

  beforeEach(() => {
      mainPage.navigateToHome();
  });

  it('should display the heading Pastebin Application', () => {
    
      expect(mainPage.getPastebinHeading()).toEqual("Pastebin Application");

     
  });

   it('should have a table header', () => {
  
      expect(mainPage.getTableHeader()).toContain("id Title Language Code");
     
  })
  it('table should have at least one row', () => {
    
      expect(mainPage.getFirstRowData()).toContain("Hello world");
  })
  
  it('should have the app-add-paste tag', () => {
      expect(mainPage.isAddPasteTagPresent()).toBeTruthy();
  })
});

组织测试和重构

测试的组织方式应使整体结构显得有意义而直接。 这是组织端到端测试时应牢记的一些指导性原则。

  • 将E2E测试与单元测试分开。
  • 明智地将E2E测试分组。 以与项目结构相匹配的方式组织测试。
  • 如果有多个页面,则页面对象应具有自己的单独目录。
  • 如果页面对象有一些共同的方法(例如navigateToHome() ),请创建一个基本页面对象。 其他页面模型可以从基本页面模型继承。
  • 使您的测试彼此独立。 您不希望由于UI的微小更改而导致所有测试失败,是吗?
  • 使页面对象定义没有断言/期望。 断言应在spec文件中进行。

按照上述准则,页面对象层次结构和文件组织应如下所示。

量角器中的页面对象层次结构和e2e测试结构

我们已经介绍了pastebin.po.tsmainPage.e2e-spec.ts 。 这是其余文件。

基本页面对象
/* path: e2e/page-objects/base.po.ts */

import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor';

export class Base {

    /* Navigational methods */
	navigateToHome():promise.Promise<any> {
    	return browser.get('/');
  	}

  	navigateToAbout():promise.Promise<any>  {
  	 	return browser.get('/about');
  	}

  	navigateToContact():promise.Promise<any>  {
  		return browser.get('/contact');
  	}

  	/* Mock data for creating a new Paste and editing existing paste */

	getMockPaste(): any {
		let paste: any = { title: "Something  here",language: "Ruby",paste: "Test"}
  		return paste;
	}

	getEditedMockPaste(): any {
		let paste: any = { title: "Paste 2", language: "JavaScript", paste: "Test2" }
		return paste;
	}
	
	/* Methods shared by addPaste and viewPaste */

	getInputTitle():ElementFinder {
		return element(by.name("title"));
	}

	getInputLanguage(): ElementFinder {
		return element(by.name("language"));
	}

	getInputPaste(): ElementFinder {
		return element(by.name("paste"));

	}
}
添加粘贴页面对象
Angular蓝图中的AddPaste组件的端到端测试
AddPaste组件的蓝图

/* Path: e2e/page-objects/add-paste.po.ts */

import { browser, by, element, promise, ElementFinder, ElementArrayFinder } from 'protractor';
import { Base } from './base.po';
export class AddPaste extends Base  {
    
	getAddPaste():ElementFinder {
		return element(by.tagName('app-add-paste'));
	}
	
	/* Create Paste button */
	getCreateButton(): ElementFinder {
		return this.getAddPaste().element(by.buttonText("create Paste"));
	}

	isCreateButtonPresent() : promise.Promise<boolean> {
		return this.getCreateButton().isPresent();
	}

	clickCreateButton(): promise.Promise<void> {
		return this.getCreateButton().click();
	}

	/*Create Paste Modal */

	getCreatePasteModal(): ElementFinder {
		return this.getAddPaste().element(by.id("source-modal"));
	}

	isCreatePasteModalPresent() : promise.Promise<boolean> {
		return this.getCreatePasteModal().isPresent();
	}

	/*Save button */
	getSaveButton(): ElementFinder {
		return this.getAddPaste().element(by.buttonText("Save"));
	}
	
	clickSaveButton():promise.Promise<void> {
		return this.getSaveButton().click();
	}

	/*Close button */

	getCloseButton(): ElementFinder {
		return this.getAddPaste().element(by.buttonText("Close"));
	}

	clickCloseButton():promise.Promise<void> {
		return this.getCloseButton().click();
	}
	

	/* Get Input Paste values from the Modal window */
	getInputPasteValues(): Promise<string[]> {
		let inputTitle, inputLanguage, inputPaste;

		// Return the input values after the promise is resolved
		// Note that this.getInputTitle().getText doesn't work
		// Use getAttribute('value') instead
		return Promise.all([this.getInputTitle().getAttribute("value"), this.getInputLanguage().getAttribute("value"), this.getInputPaste().getAttribute("value")])
		.then( (values) => {
			return values;
		});
		
	}

	/* Add a new Paste */

	addNewPaste():any {
		let newPaste: any = this.getMockPaste();

		//Send input values
		this.getInputTitle().sendKeys(newPaste.title);
		this.getInputLanguage()
			.element(by.cssContainingText('option', newPaste.language)).click();
		this.getInputPaste().sendKeys(newPaste.paste);

		//Convert the paste object into an array
  		return Object.keys(newPaste).map(key => newPaste[key]);

	}

}
添加粘贴规范文件
/* Path: e2e/addNewPaste.e2e-spec.ts */

import { Pastebin } from './page-objects/pastebin.po';
import { AddPaste } from './page-objects/add-paste.po';
import { browser, protractor } from 'protractor';

/* Scenarios to be Tested 
  1. AddPaste Page should have a button when clicked on should present a modal window 
  2. The modal window should accept the new values and save them
  4. The saved data should appear in the MainPage
  3. Close button should work
*/

describe('Add-New-Paste page', () => {
 
  const addPastePage: AddPaste = new AddPaste();
  const mainPage: Pastebin = new Pastebin();

  beforeEach(() => {
 
    addPastePage.navigateToHome();
  });

  it('should have an Create Paste button and modal window', () => {

    expect(addPastePage.isCreateButtonPresent()).toBeTruthy("The button should exist");
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window shouldn't appear, not yet!");
    
    addPastePage.clickCreateButton();
    
    expect(addPastePage.isCreatePasteModalPresent()).toBeTruthy("The modal window should appear now");
   

  });

  it("should accept and save input values", () => {
   
    addPastePage.clickCreateButton();
     
    const emptyInputValues = ["","",""];
    expect(addPastePage.getInputPasteValues()).toEqual(emptyInputValues);
    
    const newInputValues = addPastePage.addNewPaste();
    expect(addPastePage.getInputPasteValues()).toEqual(newInputValues);

    addPastePage.clickSaveButton();
 
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone");
    expect(mainPage.getLastRowData()).toContain("Something here");

  });

  it("close button should work", () => {
    
    addPastePage.clickCreateButton();
    addPastePage.clickCloseButton();
    
    expect(addPastePage.isCreatePasteModalPresent()).toBeFalsy("The modal window should be gone");
     
  });
  
});

练习题

但是,还缺少一些内容:“ 查看粘贴”按钮的测试和单击该按钮后弹出的模式窗口。 我将把它作为练习留给您。 但是,我会给您一个提示。

页面对象的结构和ViewPastePage的规范与AddPastePage的相似。

角度蓝图中ViewPaste组件的端到端测试
ViewPaste组件的蓝图

这是您需要测试的方案:

  1. ViewPaste页面上应该有一个按钮,并且在单击时应该会弹出一个模式窗口。
  2. 模态窗口应显示最近添加的粘贴的粘贴数据。
  3. 模态窗口应允许您更新值。
  4. 删除按钮应该起作用。

尽可能尝试遵守准则。 如果您有疑问,请切换到final分支以查看代码的最终草案。

包起来

所以你有它。 在本文中,我们介绍了使用Protractor为Angular应用程序编写端到端测试。 我们首先讨论了单元测试与e2e测试,然后学习了有关设置,配置和运行Protractor的知识。 本教程的其余部分集中于为演示Pastebin应用程序编写实际测试。

请让我知道您对使用量角器编写测试或为Angular编写测试的想法和经验。 我很想听听他们的声音。 谢谢阅读!

翻译自: https://code.tutsplus.com/tutorials/getting-started-with-end-to-end-testing-in-angular-using-protractor--cms-29318

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值