单元测试指引



一、序言


什么是单元测试


    1、单元测试是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确;

    2、通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为;

    3、执行单元测试,是为了证明某段代码的行为确实和开发者所期望的一致。

为什么要使用单元测试


    1、单元测试不但会使你的工作完成的更轻松,而且会令你的设计变得更好,甚至大大减少你花在调试上面的时间;

    2、单元测试可以提高底层代码的正确性,从而提高调用它的高层代码的正确性;

    3、使用单元测试这个简单有效的技术就是为了令代码变得更加完美。

如何进行单元测试(单元测试的指导性原则)


    1、首先要考虑在编写这些测试方法之前,如何测试那些可疑的方法。

    2、下一步,运行测试本身,或者同时运行系统模块的所有其他测试,甚至运行整个系统的测试,前提是这些测试运行起来相对比较快。

    3、确认每个测试究竟是通过了还是失败了。



二、JUnit介绍


构建单元测试


遵循命名习惯

    1、测试方法以test开头(尽量遵循该命名习惯)

    2、如果有一个被测方法的名称为createAccount(),那么测试方法的名称就应该为testCreateAccount()。

测试代码必须要做的几件事情

    1、准备测试所需要的各种条件(创建所有必须的对象,分配必要的资源等等)

    2、调用要测试的方法

    3、验证被测试方法的行为和期望是否一致

    4、完成后清理各种资源

注意:当执行测试代码的时候,请记住你从来不直接运行产品代码

JUnit的各种断言


断言:JUnit提供了一些辅助方法,用于帮助你确定某个被测试方法是否工作正常。通常而言,我们把这些辅助方法统称为“断言”。


断言是单元测试最基本的组成部分。


assertEquals

    assertEquals([String message],expected,actual)这是使用最多的断言形式。Expected:你的期望值;actual:被测试代码实际产生的值;message:一个可选的消息,如果提供的话,在发生错误的时候报告该消息。

assertNull / assertNotNull

    参数形式:([String message],Object object)
    验证一个给定的对象是否为null(或者非null)

assertSame / assertNotSame

    参数同assertEquals
    验证expected参数和actual参数所引用的是否为相(不)同的对象.

assertTrue / assertFalse

    参数形式:([String message],boolean condition)
    验证给定的二元条件是否为真(假).

Fail([String message]):该断言将会使测试立即失败.


JUnit测试骨架


用JUnit写测试真正需要做的就三件事情:

    import语句引入所有junit依赖包。
    一个extends语句让你的类从TestCase继承,或者目前流行的注解方式

辅助做的事情:

    覆盖建立环境的方法:setUp()
    覆盖清理环境的方法:tearDown()
    真正的测试方法:test****()
    在test***()方法中调用JUnit的断言或者自定义的断言

三、一些原则


测试哪些内容


结果是否正确

    如果代码能够运行正确,我要怎么才知道他是正确的呢?
    1. 至少需要确认代码所做的和你的期望是一致的。
    使用数据文件
    2. 对于有大量测试数据的测试,考虑使用一个独立的数据文件来存储这些测试数据,然后单元测试读取该文件。
    3. 对于验证被测方法是正确的这件事情,如果某些做法能够使它变得更加容易,那就采纳它吧。

边界条件

    一个想到可能的边界条件的简单办法就是记住助记短语CORRECT。
    1. Conformance(一致性)-值是否和预期的一致
    2. Ordering(顺序性)-值是否如应该的那样,是有序或者无序的
    3. Range(区间性)-值是否位于合理的最小值和最大值之间
    4. Reference(依赖性)-代码是否引用了一些不在代码本身控制范围内的外部资源
    5. Existence(存在性)-值是否存在(是否非null,非0,在一个集合中等)
    6. Cardinatity(基数性)-是否恰好有足够的值
    7. Time(绝对或者相对的时间性)-所有的事情的发生是否是有序的?是否是在正确的时刻?是否恰好及时?

检查反向关联

    对于某些方法,可以使用反向的逻辑关系来验证他们。
    用对结果进行平方的方式来检查一个计算平方根的方法,然后测试结果是否和原数据很接近
    为了检查某条记录是否成功插入了数据库,你可以通过查询这条记录来验证。

使用其他手段来实现交叉检查

    计算一个量会存在一个以上的方法。可以利用另一个方法来交叉测试原方法的结果。
    使用类本身不同组成部分的数据来进行交叉检查。如图书馆的数据系统,可以通过借出数和库存数之和必定等于所藏书籍总量这种约束来进行检查。

强制产生错误条件

真实世界中出现的错误:磁盘满,网络断等,可以利用Mock对象
环境方面的约束的考虑:系统过载、内存耗光等

性能特性

    要检查的是性能特性,而不是性能本身。
    性能特性有着“随着输入尺寸慢慢变大,问题慢慢变复杂”的趋势
    性能特性的快速回归测试
    由于测试时间较长,可以考虑每隔几天运行一次
    需要使用一些测试工具:如免费的JUnitPerf

设计话题


面向测试的设计

    在软件设计与实现中,“关注点的分离”也许是唯一一个称得上最重要的概念。
    主要涉及:封装性、正交性、耦合性和所有其他一切有助于编写shy code的计算机科学概念。
    通过有意的设计出方便测试的代码,你可以让代码具有更好的结构和可维护性。
    需要分离的关注点首先应该是自己编写的代码。
    如果你的测试代码看起来非常丑陋甚至难以编写的话,他暗示你的设计可能需要进行修改,直到让代码易于测试为止。

为测试而重构

    当一个系统的代码很难测试的时候,分离出各个不同的对象及其操作。
    完成后,确认其他的代码还会存在同样的问题吗?
    之后通过分析,重构系统中的代码。
    最终结果—将会获得一个比之前更加简洁的设计。

测试类的不变性

    结构化
    1. 最普遍的不变性是结构化属性。
    2. 结构化错误通常都会导致程序抛出一个异常,或者突然终止。
    3. 更加重要的是,不变性检查让你确信一个事实:你并不是基于某种巧合,才令测试通过的。
    数学不变性
    4. 一些约束通常都与数学相关。
    5. 在此,需要考虑的是数据结构的逻辑模型。
    6. 不变性并非一个短暂的条件,它永远都是为真的。
    数据一致性
    7. 很多情况下,一个对象可以以多种不同的方式来表示同一份数据。
    8. 虽然方式不同,但是数据是同一份,数据是一致的。

测试驱动的设计

    如果你在编写实现代码之前,就先编写他们的测试代码,那么你就是在使用测试驱动的开发这种技术。
    通过先编写测试,你需要把自己当成代码的用户,而不是代码的实现者。
    通过让测试代码的编写变得更加简洁和容易的效果,你同时也会令产品代码的编写变得更加简洁和容易。

测试无效的参数

    谁负责检查输入数据的有效性
    在一个软件系统中,任何面向外部的部分(一个UI,或者两一个系统的接口)都需要是健壮的,而且不能允许让任何不正确的、或者无效的数据顺利通过。    因此,你需要对他们进行测试。

四、单元测试实例

    使用Maven + SpringBoot/SpringCloud + JUnit + Jacoco,构建程序员单元测试生态圈,生成项目对应的单元测试报告、代码覆盖率报告。

全局结构

​​在这里插入图片描述

​​在pom.xml中添加环境的依赖

SpringBoot单元测试

<dependency>
	 <groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>

在单元测试中,用于动态修改系统参数,管理测试用例

<dependency>
	<groupId>com.github.stefanbirkner</groupId>
	<artifactId>system-rules</artifactId>
	<version>1.16.1</version> 
	<scope>test</scope>
</dependency>

代码覆盖率插件

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-rules</artifactId>
    <version>1.16.1</version>
    <scope>test</scope>
</dependency>
<!--检查代码覆盖率的插件配置-->
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>${jacoco.version}</version>
    <!--这里的execution ,每一个执行的goal,对应的id必须是唯一的-->
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <!--这个check:对代码进行检测,控制项目构建成功还是失败-->
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
        </execution>
        <!--这个report:对代码进行检测,然后生成index.html在 target/site/index.html中可以查看检测的详细结果-->
        <execution>
            <id>report</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>

    <!-- Configuration 里面写配置信息 -->
    <configuration>
        <!-- rules里面指定覆盖规则 -->
        <rules>
            <rule implementation="org.jacoco.maven.RuleConfiguration">
                <element>BUNDLE</element>
                <limits>
                    <!-- 指定方法覆盖到80% -->
                    <limit implementation="org.jacoco.report.check.Limit">
                        <counter>METHOD</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.80</minimum>
                    </limit>
                    <!-- 指定指令覆盖到80% -->
                    <limit implementation="org.jacoco.report.check.Limit">
                        <counter>INSTRUCTION</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.80</minimum>
                    </limit>
                    <!-- 指定行覆盖到80% -->
                    <limit implementation="org.jacoco.report.check.Limit">
                        <counter>LINE</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.80</minimum>
                    </limit>
                    <!-- 指定类覆盖到100%,不能遗失任何类 -->
                    <limit implementation="org.jacoco.report.check.Limit">
                        <counter>CLASS</counter>
                        <value>MISSEDCOUNT</value>
                        <maximum>0</maximum>
                    </limit>

                </limits>
            </rule>
        </rules>
    </configuration>


</plugin>


单元测试案例管理

package test.config;

import io.swagger.annotations.Example;
import org.apache.commons.lang.StringUtils;
import org.junit.Rule;
import org.junit.contrib.java.lang.system.EnvironmentVariables;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.CollectionUtils;
import org.yaml.snakeyaml.Yaml;

import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.*;


@Configuration
public class TestValuesConfig {

    private static Logger log = LoggerFactory.getLogger(TestValuesConfig.class);

    @Value("${spring.profiles.active: dev}")
    protected String springProfilesActive;

    @Rule
    protected final EnvironmentVariables environmentVariables = new EnvironmentVariables();

    @Bean
    public EnvironmentVariables setEnvValues() {

        if(StringUtils.isNotBlank(springProfilesActive)) {
            springProfilesActive = springProfilesActive.trim();
        }

        StringBuffer testValuesYml = new StringBuffer("test-values");
        if(StringUtils.isNotBlank(springProfilesActive)) {
            testValuesYml.append("-").append(springProfilesActive).append(".yml");
        }

        Map<String, String> configValues = this.loadYamlFile(testValuesYml.toString());
        if (!CollectionUtils.isEmpty(configValues)) {
            for(String configKey : configValues.keySet()) {
                if(StringUtils.isNotBlank(configKey)) {
                    environmentVariables.set(configKey.trim(), configValues.get(configKey).trim());
                    log.info("environmentVariables.set({}, {})",configKey.trim(), configValues.get(configKey).trim());
                }
            }
        }

        //environmentVariables.set("eureka.client.service-url.defaultZone", "http://192.168.197.116:9000/eureka/,http://192.168.197.116:9001/eureka/");
        //environmentVariables.set("spring.cloud.client.ipAddress", "192.168.198.156");
        //environmentVariables.set("server.port", "9202");

        return environmentVariables;

    }

    private static Map<String, String> loadYamlFile(String fileName) {
        Map<String, String> result = new HashMap();
        InputStream fileInputStream = null;
        Map<String, Object> resultObj = null;
        try {
            Yaml yaml = new Yaml();
            URL url = TestValuesConfig.class.getClassLoader().getResource(fileName);
            if (url != null) {
                fileInputStream = new FileInputStream(url.getFile());
                //获取yaml文件中的配置数据,然后转换为Map,
                resultObj = yaml.loadAs(fileInputStream, HashMap.class);
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }finally {
            try {
                if(fileInputStream!=null) {
                    fileInputStream.close();
                }
            }catch (Exception e) {
            }
        }
        if(!CollectionUtils.isEmpty(resultObj)) {
            for(String key : resultObj.keySet()) {
                Object value = resultObj.get(key);
                if(value instanceof Map) {
                    Map valueMap = (Map)value;
                    iteratorYml(result, valueMap, key);
                }else {
                    result.put(key, String.valueOf(value));
                }
            }
        }
        return result;
    }

    private static void iteratorYml(Map allMap, Map map, String key){
        Iterator iterator = map.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry entry = (Map.Entry) iterator.next();
            Object key2 = entry.getKey();
            Object value = entry.getValue();
            if(value instanceof Map){
                if(key==null){
                    iteratorYml(allMap, (Map)value,key2.toString());
                }else{
                    iteratorYml(allMap, (Map)value,key+"."+key2.toString());
                }
            }else{
                if(key==null){
                    allMap.put(key2.toString(), String.valueOf(value));
                }
                if(key!=null){
                    allMap.put(key+"."+key2.toString(), String.valueOf(value));
                }
            }
        }

    }


}


建立Abstract基础测试类 BaseTest

package test;

import com.rfchina.etg.platform.openaccess.OpenAccessServiceApplication;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScans;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;


//@RunWith(SpringJUnit4ClassRunner.class)
@RunWith(SpringRunner.class)
@ComponentScans(value = {@ComponentScan("test.*")})
@Configuration
@SpringBootTest(classes={OpenAccessServiceApplication.class, BaseTest.class})
public abstract class BaseTest {

    private Logger log = LoggerFactory.getLogger(getClass());

    @Autowired
    protected WebApplicationContext context;

    @Value("${spring.profiles.active: dev}")
    protected String springProfilesActive;

    protected MockMvc mockMvc;

    @Before
    public void testBefore() {
        //setEnv();
        setUp();
        childBefore();
    }

    public void setUp() {
        try {
            mockMvc = MockMvcBuilders
                    .webAppContextSetup(context)
                    .build();
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }

    protected void childBefore() {
    }

}


日常周期性的单元测试编写工作


单元测试类编写


    注意:@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)是应付方法是有顺序的情况,譬如场景:方法1(挑选商品进入购物车)->方法2(选择送货地址)->方法3(支付)->方法4(购买成功,可评价),NAME_ASCENDING让方法遵循字典的自然顺序的执行。

package test.controller;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.rfchina.etg.platform.common.vo.Request;
import com.rfchina.etg.platform.common.vo.Result;
import com.rfchina.etg.platform.model.vo.oauth.AccessTokenVo;
import com.rfchina.etg.platform.model.vo.openaccess.app.AppInfoRequst;
import com.rfchina.etg.platform.model.vo.openaccess.app.AppInfoResponse;
import org.junit.Assert;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.runners.MethodSorters;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.util.CollectionUtils;
import test.BaseTest;
import test.component.TokenTestService;

import java.util.Date;


@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
public class AppControllerTest extends BaseTest {

    private Logger log = LoggerFactory.getLogger(getClass());

    @Autowired
    private TokenTestService tokenTestService;

    @Value("${test.case.AppControllerTest.userName}")
    private String userName;

    @Value("${test.case.AppControllerTest.appType}")
    private String appType;

    private AccessTokenVo accessTokenVo;

    @Test
    public void test_1_oauthAppToken() {
        log.debug("test_1_oauthAppToken() start");
        this.accessTokenVo = this.tokenTestService.oauthAppToken();
        log.debug("test_1_oauthAppToken()-accessTokenVo: {}", JSONObject.toJSONString(accessTokenVo));
    }


    @Test
    public void test_2_getAppInfo() {

        log.debug("test_2_getAppInfo() start");

        boolean checkOk = false;

        try {

            Request<AppInfoRequst> requestBody = new Request<>();
            requestBody.setTimestamp(new Date().getTime());
            AppInfoRequst appInfoRequst = new AppInfoRequst();
            appInfoRequst.setUserName(this.userName);
            appInfoRequst.setAppType(this.appType);
            requestBody.setData(appInfoRequst);

            RequestBuilder request = null;
            //路径
            request = MockMvcRequestBuilders.post("/app/getAppInfo")
                    //参数
                    .content(JSONObject.toJSONString(requestBody))
                    //接受的数据类型
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    ;

            MvcResult mvcResult = this.mockMvc.perform(request)
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andReturn();

            String content = mvcResult.getResponse().getContentAsString();

            Result<AppInfoResponse> result = JSON.parseObject(content, Result.class);
            AppInfoResponse data = JSON.parseObject(JSONObject.toJSONString(result.getData()), AppInfoResponse.class);

            checkOk = !CollectionUtils.isEmpty(data.getMerchantInfos());

        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }

        Assert.assertTrue(checkOk);

        log.debug("test_2_getAppInfo()-checkOk: {}", checkOk);

    }

    @Test
    public void test_3() {

    }

    @Test
    public void test_4() {

    }

    @Test
    public void test_5() {

    }

    @Test
    public void a1() {

    }

    @Test
    public void a2() {

    }

}


单元测试案例存放点


    test-values-dev.yml/test-values-test.yml,应对不同环境有不同的测试案例,测试用例规范:test.case.测试业务**

server:
  port: 9202
  servlet:
    session:
      cookie:
        name: OPENPLATFORM_ACCESS_SERVICE_UNITTEST_SESSION


test:
  case:
    base:
      clientId: RfHouseIntegralMall-clientId
      clientSecret: RfHouseIntegralMall-clientSecret
    AppControllerTest:
      userName: 18138728613
      appType: INTEGRAL_MALL


开始单元测试

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

Maven构建,产生单元测试、代码覆盖率报告



打开Maven视图
在这里插入图片描述

避免旧数据的混淆,清理项目
在这里插入图片描述

运行Test或者Package,构建项目,系统自动执行所有单元测试,并生成对应的报告

在这里插入图片描述

代表构建成功

在这里插入图片描述

进入项目构建目录,如:\target目录中
在这里插入图片描述

单元测试报告
在这里插入图片描述

代码覆盖报告
可查看项目单元测试覆盖率,细致到类、方法、分支
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


五、做桩测试


    在单元测试中,模拟对象可以模拟复杂的、真实的(非模拟)对象的行为, 如果真实的对象无法放入单元测试中,使用模拟对象就很有帮助。减少外部接口依赖,能达到快速单元测试的目的。

使用场景


    在下面的情形,可能需要使用模拟对象来代替真实对象:
    真实对象的行为是不确定的(例如,当前的时间或当前的温度);
    真实对象很难搭建起来;
    真实对象的行为很难触发(例如,网络错误);
    真实对象速度很慢(例如,一个完整的数据库,在测试之前可能需要初始化);
    真实的对象是用户界面,或包括用户界面在内;
    真实的对象使用了回调机制;
    真实对象可能还不存在;
    真实对象可能包含不能用作测试(而不是为实际工作)的信息和方法。

使用Mockito做桩


1、 引入依赖

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>2.21.0</version>
    <scope>test</scope>
</dependency>```
&ensp;
2、	确定需要Mock的方法,譬如从数据库或者网络获取的数据
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190520151007992.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2hlbGxvNDQxMzczNDc3,size_16,color_FFFFFF,t_70)
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190520151016980.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2hlbGxvNDQxMzczNDc3,size_16,color_FFFFFF,t_70)
&ensp;
3、修改基础单元测试BaseTest的初始化,增加Mockito初始化的步骤。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190520151102862.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2hlbGxvNDQxMzczNDc3,size_16,color_FFFFFF,t_70)
&ensp;
4、编写测试用例

![在这里插入图片描述](https://img-blog.csdnimg.cn/20190520151135692.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2hlbGxvNDQxMzczNDc3,size_16,color_FFFFFF,t_70)
&ensp;
5、运行单元测试用例
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190520151214705.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2hlbGxvNDQxMzczNDc3,size_16,color_FFFFFF,t_70)
&ensp;
运行结果看来,跟我们设置的输出一样,为:“ABC”
&ensp;
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值