spring_在Spring MVC Web应用程序中添加社交登录:单元测试

spring

spring

Spring Social 1.0具有spring-social-test模块,该模块为测试Connect实现和API绑定提供支持。 该模块已从Spring Social 1.1.0中删除,并由Spring MVC Test框架替换。

问题在于,实际上没有有关为使用Spring Social 1.1.0的应用程序编写单元测试的信息。

这篇博客文章解决了这个问题

在此博客文章中,我们将学习如何为示例应用程序的注册功能编写单元测试,该功能是在本Spring Social教程的前面部分中创建的。

注意:如果您尚未阅读Spring Social教程的先前部分,建议您在阅读此博客文章之前先阅读它们。 以下描述了此博客文章的前提条件:

让我们从发现如何使用Maven获得所需的测试标准开始。

使用Maven获取所需的依赖关系

通过在POM文件中声明以下依赖关系,我们可以获得所需的测试依赖关系:

  • FEST声明(1.4版)。 FEST-Assert是一个库,可为编写断言提供流畅的接口
  • hamcrest-all(1.4版)。 我们使用Hamcrest匹配器在单元测试中编写断言。
  • JUnit (版本4.11)。 我们还需要排除hamcrest-core,因为我们已经添加了hamcrest-all依赖项。
  • 全模拟(版本1.9.5)。 我们使用Mockito作为我们的模拟库。
  • Catch-Exception(版本1.2.0)。 catch-exception库可帮助我们在不终止测试方法执行的情况下捕获异常,并使捕获的异常可用于进一步分析。 我们已经排除了嘲笑核心依赖,因为我们已经添加了嘲笑所有依赖。
  • Spring测试(版本3.2.4.RELEASE)。 Spring Test Framework是一个框架,可以为基于Spring的应用程序编写测试。

pom.xml文件的相关部分如下所示:

<dependency>
    <groupId>org.easytesting</groupId>
    <artifactId>fest-assert</artifactId>
    <version>1.4</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest-all</artifactId>
    <version>1.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.11</version>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <artifactId>hamcrest-core</artifactId>
            <groupId>org.hamcrest</groupId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-all</artifactId>
    <version>1.9.5</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.googlecode.catch-exception</groupId>
    <artifactId>catch-exception</artifactId>
    <version>1.2.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-test</artifactId>
    <version>3.2.4.RELEASE</version>
    <scope>test</scope>
</dependency>

让我们动起来,快速浏览一下Spring Social的内部。

展望Spring社交网络

我们可能在本教程的第二部分中还记得RegistrationController类负责呈现注册表单并处理注册表单的表单提交。 它使用ProviderSignInUtils类有两个目的:

  1. 呈现注册表单时,如果用户正在使用社交登录创建新的用户帐户,则RegistrationController类会预先填充表单字段。表单对象是通过使用所用SaaS API提供程序提供的信息来预先填充的。 此信息存储到Connection对象。 控制器类通过调用ProviderSignInUtils类的静态getConnection()方法来获取Connection对象。
  2. 创建新用户帐户后,如果用户帐户是使用社交登录创建的,则RegistrationConnection会将Connection对象保留在数据库中。控制器类通过调用ProviderSignInUtils类的handlePostSignUp()方法来实现此目的

如果我们想了解ProviderSignInUtils类的作用,请看一下其源代码。 ProviderSignInUtils类的源代码如下所示:

package org.springframework.social.connect.web;

import org.springframework.social.connect.Connection;
import org.springframework.web.context.request.RequestAttributes;

public class ProviderSignInUtils {
   
    public static Connection<?> getConnection(RequestAttributes request) {
        ProviderSignInAttempt signInAttempt = getProviderUserSignInAttempt(request);
        return signInAttempt != null ? signInAttempt.getConnection() : null;
    }

    public static void handlePostSignUp(String userId, RequestAttributes request) {
        ProviderSignInAttempt signInAttempt = getProviderUserSignInAttempt(request);
        if (signInAttempt != null) {
            signInAttempt.addConnection(userId);
            request.removeAttribute(ProviderSignInAttempt.SESSION_ATTRIBUTE, RequestAttributes.SCOPE_SESSION);
        }      
    }
   
    private static ProviderSignInAttempt getProviderUserSignInAttempt(RequestAttributes request) {
        return (ProviderSignInAttempt) request.getAttribute(ProviderSignInAttempt.SESSION_ATTRIBUTE, RequestAttributes.SCOPE_SESSION);
    }
}

我们可以从ProviderSignInUtils类的源代码中看到两件事:

  1. getConnection()方法从会话中获取ProviderSignInAttempt对象。 如果获取的对象为null,则返回null。 否则,它将调用ProviderSignInAttemptgetConnection()方法并返回Connection对象。
  2. handlePostSignUp()方法从会话获取ProviderSignInAttempt对象。 如果找到该对象,它将调用ProviderSignInAttempt类的addConnection()方法,并从会话中删除找到的ProviderSignInAttempt对象。

显然,为了为RegistrationController类编写单元测试,我们必须找出一种创建ProviderSignInAttempt对象并将创建的对象设置为session的方法。

让我们找出这是如何完成的。

创建测试双打

如我们所知,如果要为RegistrationController类编写单元测试,则必须找到一种创建ProviderSignInAttempt对象的方法。 本节介绍了如何通过使用测试双打来实现此目标。

让我们继续前进,找出如何在单元测试中创建ProviderSignInAttempt对象。

创建ProviderSignInAttempt对象

如果我们想了解如何创建ProviderSignInAttempt对象,则必须仔细查看其源代码。 ProviderSignInAttempt类的源代码如下所示:

package org.springframework.social.connect.web;

import java.io.Serializable;

import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionData;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.DuplicateConnectionException;
import org.springframework.social.connect.UsersConnectionRepository;

@SuppressWarnings("serial")
public class ProviderSignInAttempt implements Serializable {

    public static final String SESSION_ATTRIBUTE = ProviderSignInAttempt.class.getName();

    private final ConnectionData connectionData;
   
    private final ConnectionFactoryLocator connectionFactoryLocator;
   
    private final UsersConnectionRepository connectionRepository;
       
    public ProviderSignInAttempt(Connection<?> connection, ConnectionFactoryLocator connectionFactoryLocator, UsersConnectionRepository connectionRepository) {
        this.connectionData = connection.createData();
        this.connectionFactoryLocator = connectionFactoryLocator;
        this.connectionRepository = connectionRepository;      
    }
       
    public Connection<?> getConnection() {
        return connectionFactoryLocator.getConnectionFactory(connectionData.getProviderId()).createConnection(connectionData);
    }

    void addConnection(String userId) {
        connectionRepository.createConnectionRepository(userId).addConnection(getConnection());
    }
}

如我们所见, ProviderSignInAttempt类具有三个依赖关系,如下所示:

首先想到的是模拟这些依赖关系。 尽管这似乎是一个好主意,但是这种方法有两个问题:

  1. 在编写的每个测试中,我们都必须配置模拟对象的行为。 这意味着我们的测试将更难理解。
  2. 我们正在将Spring Social的实现细节泄漏到我们的测试中。 这将使我们的测试难以维护,因为如果实施Spring Social更改,我们的测试可能会被破坏。

显然,模拟并不是解决此问题的最佳解决方案。 我们必须记住,即使模拟是一种有价值且方便的测试工具, 我们也不应过度使用它

这就产生了一个新问题:

如果无法进行模拟,那么什么才是正确的工具?

这个问题的答案可以从Martin Fowler一篇文章中找到。 在本文中,马丁·福勒(Martin Fowler)指定了一个称为存根的测试双精度形式,如下所示:

存根提供对测试过程中进行的呼叫的固定答复,通常通常根本不响应测试中编程的内容。 存根还可以记录有关呼叫的信息,例如,电子邮件网关存根可以记住“已发送”的消息,或者仅记住“已发送”的消息数量。

使用存根非常有意义,因为我们对两件事感兴趣:

  1. 我们需要能够配置存根返回的Connection <?>对象。
  2. 创建新的用户帐户后,我们需要验证连接是否与数据库保持一致。

我们可以按照以下步骤创建一个实现这些目标的存根:

  1. 创建一个TestProviderSignInAttempt类,该类扩展了ProviderSignInAttempt类。
  2. 将私有连接字段添加到该类,并将添加的字段的类型设置为Connection <?> 。 此字段包含对用户和SaaS API提供程序之间的连接的引用。
  3. 将私有连接字段添加到该类,并将添加到的字段的类型设置Set <String> 。 该字段包含持久连接的用户标识。
  4. 向创建的类添加一个将Connection <?>对象作为构造函数参数的构造函数。 通过执行以下步骤来实现构造函数:
    1. 调用ProviderSignInAttempt类的构造函数,并将Connection <?>对象作为构造函数参数传递。 将其他构造函数参数的值设置为null
    2. 将作为构造函数参数提供的Connection <?>对象设置为connection字段。
  5. 重写ProviderSignInAttempt类的getConnection()方法,并通过将存储的对象返回到连接字段来实现它。
  6. 重写ProviderSignInAttempt类的addConnection(String userId)方法,并通过将作为方法参数给出的用户ID添加到连接集中来实现它。
  7. 将公共getConnections()方法添加到创建的类中,并通过返回连接集来实现它。

TestProviderSignInAttempt的源代码如下所示:

package org.springframework.social.connect.web;

import org.springframework.social.connect.Connection;

import java.util.HashSet;
import java.util.Set;

public class TestProviderSignInAttempt extends ProviderSignInAttempt {

    private Connection<?> connection;

    private Set<String> connections = new HashSet<>();

    public TestProviderSignInAttempt(Connection<?> connection) {
        super(connection, null, null);
        this.connection = connection;
    }

    @Override
    public Connection<?> getConnection() {
        return connection;
    }

    @Override
    void addConnection(String userId) {
        connections.add(userId);
    }

    public Set<String> getConnections() {
        return connections;
    }
}

让我们继续前进,找出如何创建在单元测试中使用的Connection <?>类。

创建连接类

创建的连接类是一个存根类,它模拟“真实”连接类的行为,但是它没有实现与OAuth1OAuth2连接关联的任何逻辑。 同样,此类必须实现Connection接口。

我们可以按照以下步骤创建此存根类:

  1. 创建一个TestConnection类,该类扩展了AbstractConnection类。 AbstractConnection类是基类,它定义了所有连接实现共享的状态和行为。
  2. connectionData字段添加到创建的类。 将字段的类型设置为ConnectionDataConnectionData是一个数据传输对象,其中包含与使用的SaaS API提供程序的连接的内部状态。
  3. userProfile字段添加到创建的类。 将字段的类型设置为UserProfile 。 此类表示所使用的SaaS API提供程序的用户配置文件,并且包含在不同服务提供程序之间共享的信息。
  4. 创建一个将ConnectionDataUserProfile对象作为构造函数参数的构造函数,并按照以下步骤实现它:
    1. 调用AbstractConnection类的构造函数,并将ConnectionData对象作为第一个构造函数参数传递。 将第二个构造函数参数设置为null
    2. 设置connectionData字段的值。
    3. 设置userProfile字段的值。
  5. 重写AbstractConnection类的fetchUserProfile()方法,并通过将存储的对象返回到userProfile字段来实现它。
  6. 重写AbstractConnection类的getAPI()方法,并通过返回null来实现它。
  7. 重写AbstractConnection类的createData()方法,并通过将存储的对象返回到connectionData字段来实现它。

TestConnection类的源代码如下所示:

package org.springframework.social.connect.support;

import org.springframework.social.connect.ConnectionData;
import org.springframework.social.connect.UserProfile;

public class TestConnection extends AbstractConnection {

    private ConnectionData connectionData;

    private UserProfile userProfile;

    public TestConnection(ConnectionData connectionData, UserProfile userProfile) {
        super(connectionData, null);
        this.connectionData = connectionData;
        this.userProfile = userProfile;
    }

    @Override
    public UserProfile fetchUserProfile() {
        return userProfile;
    }

    @Override
    public Object getApi() {
        return null;
    }

    @Override
    public ConnectionData createData() {
        return connectionData;
    }
}

让我们继续前进,弄清楚如何在单元测试中创建这些测试双打。

创建构建器类

现在,我们为单元测试创​​建了存根类。 我们的最后一步是弄清楚如何使用这些类创建TestProviderSignInAttempt对象。

至此,我们知道

  1. TestProviderSignInAttempt类的构造函数将Connection对象作为构造函数参数。
  2. TestConnection类的构造函数将ConnectionDataUserProfile对象用作构造函数参数。

这意味着我们可以按照以下步骤创建新的TestProviderSignInAttempt对象:

  1. 创建一个新的ConnectionData对象。 ConnectionData类具有单个构造函数,该构造函数将必填字段用作构造函数参数。
  2. 创建一个新的UserProfile对象。 我们可以使用UserProfileBuilder类创建新的UserProfile对象。
  3. 创建一个新的TestConnection对象,并将创建的ConnectionDataUserProfile对象作为构造函数参数传递。
  4. 创建一个新的TestProviderSignInAttempt对象,并将创建的TestConnectionConnection对象作为构造函数参数传递。

创建一个新的TestProviderSignInAttempt对象的源代码如下所示:

ConnectionData connectionData = new ConnectionData("providerId",
                 "providerUserId",
                 "displayName",
                 "profileUrl",
                 "imageUrl",
                 "accessToken",
                 "secret",
                 "refreshToken",
                 1000L);
 
 UserProfile userProfile = userProfileBuilder
                .setEmail("email")
                .setFirstName("firstName")
                .setLastName("lastName")
                .build();
               
TestConnection connection = new TestConnection(connectionData, userProfile);
TestProviderSignInAttempt signIn = new TestProviderSignInAttempt(connection);

好消息是,我们现在知道如何在测试中创建TestProviderSignInAttempt对象。 坏消息是我们无法在测试中使用此代码。

我们必须记住,我们并不是为了确保我们的代码按预期工作而编写单元测试。 每个测试用例还应该揭示我们的代码在特定情况下的行为。 如果我们通过将此代码添加到每个测试用例中来创建TestProviderSignInAttempt ,那么我们将过于强调创建测试用例所需的对象。 这意味着很难理解测试用例,并且丢失了测试用例的“本质”。

相反,我们将创建一个测试数据构建器类,该类提供了用于创建TestProviderSignInAttempt对象的流畅API。 我们可以按照以下步骤创建此类:

  1. 创建一个名为TestProviderSignInAttemptBuilder的类。
  2. 将创建新的ConnectionDataUserProfile对象所需的所有字段添加到TestProviderSignInAttemptBuilder类。
  3. 添加用于设置所添加字段的字段值的方法。 通过执行以下步骤来实现每种方法:
    1. 将作为方法参数给出的值设置为正确的字段。
    2. 返回对TestProviderSignInAttemptBuilder对象的引用。
  4. connectionData()userProfile()方法添加到TestProviderSignInAttemptBuilder类。 这些方法仅返回对TestProviderSignInAttemptBuilder对象的引用,其目的是使我们的API更具可读性。
  5. build()方法添加到测试数据构建器类。 这将按照前面介绍的步骤创建TestProviderSignInAttempt对象,并返回创建的对象。

TestProviderSignInAttemptBuilder类的源代码如下所示:

package org.springframework.social.connect.support;

import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionData;
import org.springframework.social.connect.UserProfile;
import org.springframework.social.connect.UserProfileBuilder;
import org.springframework.social.connect.web.TestProviderSignInAttempt;

public class TestProviderSignInAttemptBuilder {

    private String accessToken;

    private String displayName;

    private String email;

    private Long expireTime;

    private String firstName;

    private String imageUrl;

    private String lastName;

    private String profileUrl;

    private String providerId;

    private String providerUserId;

    private String refreshToken;

    private String secret;

    public TestProviderSignInAttemptBuilder() {

    }

    public TestProviderSignInAttemptBuilder accessToken(String accessToken) {
        this.accessToken = accessToken;
        return this;
    }

    public TestProviderSignInAttemptBuilder connectionData() {
        return this;
    }

    public TestProviderSignInAttemptBuilder displayName(String displayName) {
        this.displayName = displayName;
        return this;
    }

    public TestProviderSignInAttemptBuilder email(String email) {
        this.email = email;
        return this;
    }

    public TestProviderSignInAttemptBuilder expireTime(Long expireTime) {
        this.expireTime = expireTime;
        return this;
    }

    public TestProviderSignInAttemptBuilder firstName(String firstName) {
        this.firstName = firstName;
        return this;
    }

    public TestProviderSignInAttemptBuilder imageUrl(String imageUrl) {
        this.imageUrl = imageUrl;
        return this;
    }

    public TestProviderSignInAttemptBuilder lastName(String lastName) {
        this.lastName = lastName;
        return this;
    }

    public TestProviderSignInAttemptBuilder profileUrl(String profileUrl) {
        this.profileUrl = profileUrl;
        return this;
    }

    public TestProviderSignInAttemptBuilder providerId(String providerId) {
        this.providerId = providerId;
        return this;
    }

    public TestProviderSignInAttemptBuilder providerUserId(String providerUserId) {
        this.providerUserId = providerUserId;
        return this;
    }

    public TestProviderSignInAttemptBuilder refreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
        return this;
    }

    public TestProviderSignInAttemptBuilder secret(String secret) {
        this.secret = secret;
        return this;
    }

    public TestProviderSignInAttemptBuilder userProfile() {
        return this;
    }

    public TestProviderSignInAttempt build() {
        ConnectionData connectionData = new ConnectionData(providerId,
                providerUserId,
                displayName,
                profileUrl,
                imageUrl,
                accessToken,
                secret,
                refreshToken,
                expireTime);

        UserProfile userProfile = new UserProfileBuilder()
                .setEmail(email)
                .setFirstName(firstName)
                .setLastName(lastName)
                .build();

        Connection connection = new TestConnection(connectionData, userProfile);

        return new TestProviderSignInAttempt(connection);
    }
}

注意:在为RegistrationController类编写单元测试时,不需要调用此构建器类的所有方法。 我添加这些字段的主要原因是,当我们为示例应用程序编写集成测试时,它们将非常有用。

现在,创建新的TestProviderSignInAttempt对象的代码更加整洁,可读性更好:

TestProviderSignInAttempt socialSignIn = new TestProviderSignInAttemptBuilder()
                .connectionData()
                    .providerId("twitter")
                .userProfile()
                    .email("email")
                    .firstName("firstName")
                    .lastName("lastName")
                .build();

让我们继续前进,了解如何使用自定义FEST-Assert断言清理单元测试。

创建自定义断言

我们可以通过将标准JUnit断言替换为自定义FEST-Assert断言来清理单元测试。 我们必须创建以下三个自定义断言类:

  • 第一个断言类用于为ExampleUserDetails对象编写断言。 ExampleUserDetails类包含已登录用户的信息,该信息存储在应用程序的SecurityContext中。 换句话说,此类提供的断言用于验证登录用户的信息是否正确。
  • 第二个断言类用于为SecurityContext对象编写断言。 此类用于为其信息存储到SecurityContext的用户写断言。
  • 第三个断言类用于为TestProviderSignInAttempt对象编写断言。 此断言类用于验证是否通过使用TestProviderSignInAttempt对象创建了与SaaS API提供程序的连接。

注意:如果您不熟悉FEST-Assert,则应阅读我的博客文章,其中解释了如何使用FEST-Assert创建自定义断言,以及为什么要考虑这样做。

让我们继续。

创建ExampleUserDetailsAssert类

通过执行以下步骤,我们可以实现第一个自定义断言类:

  1. 创建一个ExampleUserDetailsAssert类,该类扩展了GenericAssert类。 提供以下类型参数:
    1. 第一个类型参数是自定义断言的类型。 将此类型参数的值设置为ExampleUserDetailsAssert
    2. 第二个类型参数是实际值对象的类型。 将此类型参数的值设置为ExampleUserDetails。
  2. 将私有构造函数添加到创建的类。 该构造函数将ExampleUserDetails对象作为构造函数参数。 通过调用超类的构造函数并传递以下对象作为构造函数参数来实现控制器:
    1. 第一个构造函数参数是一个Class对象,它指定自定义断言类的类型。 将此构造函数参数的值设置为ExampleUserDetailsAssert.class
    2. 第二个构造函数参数是实际值对象。 将作为构造函数参数给出的对象传递给超类的构造函数。
  3. 将静态assertThat()方法添加到创建的类。 此方法将ExampleUserDetails对象作为方法参数。 通过创建一个新的ExampleUserDetailsAssert对象来实现此方法。
  4. hasFirstName()方法添加到ExampleUserDetailsAssert类。 此方法将String对象作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证实际的名字是否等于作为方法参数给出的期望的名字。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  5. hasId()方法添加到ExampleUserDetailsAssert类。 此方法将Long对象作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证实际ID是否等于作为方法参数给出的预期ID。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  6. hasLastName()方法添加到ExampleUserDetailsAssert类。 此方法将String对象作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证实际的姓氏是否等于作为方法参数给出的期望的姓氏。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  7. hasPassword()方法添加到ExampleUserDetailsAssert类。 此方法将String对象作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证实际密码是否等于作为方法参数给出的预期密码。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  8. hasUsername()方法添加到ExampleUserDetailsAssert类。 此方法将String对象作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证实际用户名是否等于作为方法参数给出的预期用户名。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  9. 将一个isActive()方法添加到ExampleUserDetailsAssert类。 此方法不带方法参数,它返回ExampleUserDetailsAssert对象。
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证其信息存储在ExampleUserDetails对象中的用户是否处于活动状态。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  10. isRegisteredUser()方法添加到ExampleUserDetailsAssert类。 此方法不带方法参数,它返回ExampleUserDetailsAssert对象。
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证其信息存储在ExampleUserDetails对象中的用户是注册用户。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  11. isRegisteredByUsingFormRegistration()方法添加到ExampleUserDetailsAssert类。 此方法返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证socialSignInProvider字段的值为空。
    3. 返回对ExampleUserDetailsAssert对象的引用。
  12. isSignedInByUsingSocialSignInProvider()方法添加到ExampleUserDetailsAssert类。 此方法将SocialMediaService枚举作为方法参数,并返回ExampleUserDetailsAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的ExampleUserDetails对象不为null。
    2. 验证socialSignInProvider的值等于作为方法参数给出的预期的SocialMediaService枚举。
    3. 返回对ExampleUserDetailsAssert对象的引用。

ExampleUserDetailsAssert类的源代码如下所示:

import org.fest.assertions.Assertions;
import org.fest.assertions.GenericAssert;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class ExampleUserDetailsAssert extends GenericAssert<ExampleUserDetailsAssert, ExampleUserDetails> {

    private ExampleUserDetailsAssert(ExampleUserDetails actual) {
        super(ExampleUserDetailsAssert.class, actual);
    }

    public static ExampleUserDetailsAssert assertThat(ExampleUserDetails actual) {
        return new ExampleUserDetailsAssert(actual);
    }

    public ExampleUserDetailsAssert hasFirstName(String firstName) {
        isNotNull();

        String errorMessage = String.format(
                "Expected first name to be <%s> but was <%s>",
                firstName,
                actual.getFirstName()
        );

        Assertions.assertThat(actual.getFirstName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(firstName);

        return this;
    }

    public ExampleUserDetailsAssert hasId(Long id) {
        isNotNull();

        String errorMessage = String.format(
                "Expected id to be <%d> but was <%d>",
                id,
                actual.getId()
        );

        Assertions.assertThat(actual.getId())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(id);

        return this;
    }

    public ExampleUserDetailsAssert hasLastName(String lastName) {
        isNotNull();

        String errorMessage = String.format(
                "Expected last name to be <%s> but was <%s>",
                lastName,
                actual.getLastName()
        );

        Assertions.assertThat(actual.getLastName())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(lastName);

        return this;
    }

    public ExampleUserDetailsAssert hasPassword(String password) {
        isNotNull();

        String errorMessage = String.format(
                "Expected password to be <%s> but was <%s>",
                password,
                actual.getPassword()
        );

        Assertions.assertThat(actual.getPassword())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(password);

        return this;
    }

    public ExampleUserDetailsAssert hasUsername(String username) {
        isNotNull();

        String errorMessage = String.format(
                "Expected username to be <%s> but was <%s>",
                username,
                actual.getUsername()
        );

        Assertions.assertThat(actual.getUsername())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(username);

        return this;
    }

    public ExampleUserDetailsAssert isActive() {
        isNotNull();

        String expirationErrorMessage = "Expected account to be non expired but it was expired";
        Assertions.assertThat(actual.isAccountNonExpired())
                .overridingErrorMessage(expirationErrorMessage)
                .isTrue();

        String lockedErrorMessage = "Expected account to be non locked but it was locked";
        Assertions.assertThat(actual.isAccountNonLocked())
                .overridingErrorMessage(lockedErrorMessage)
                .isTrue();

        String credentialsExpirationErrorMessage = "Expected credentials to be non expired but they were expired";
        Assertions.assertThat(actual.isCredentialsNonExpired())
                .overridingErrorMessage(credentialsExpirationErrorMessage)
                .isTrue();

        String enabledErrorMessage = "Expected account to be enabled but it was not";
        Assertions.assertThat(actual.isEnabled())
                .overridingErrorMessage(enabledErrorMessage)
                .isTrue();

        return this;
    }

    public ExampleUserDetailsAssert isRegisteredUser() {
        isNotNull();

        String errorMessage = String.format(
                "Expected role to be <ROLE_USER> but was <%s>",
                actual.getRole()
        );

        Assertions.assertThat(actual.getRole())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(Role.ROLE_USER);

        Collection<? extends GrantedAuthority> authorities = actual.getAuthorities();

        String authoritiesCountMessage = String.format(
                "Expected <1> granted authority but found <%d>",
                authorities.size()
        );

        Assertions.assertThat(authorities.size())
                .overridingErrorMessage(authoritiesCountMessage)
                .isEqualTo(1);

        GrantedAuthority authority = authorities.iterator().next();

        String authorityErrorMessage = String.format(
                "Expected authority to be <ROLE_USER> but was <%s>",
                authority.getAuthority()
        );

        Assertions.assertThat(authority.getAuthority())
                .overridingErrorMessage(authorityErrorMessage)
                .isEqualTo(Role.ROLE_USER.name());

        return this;
    }

    public ExampleUserDetailsAssert isRegisteredByUsingFormRegistration() {
        isNotNull();

        String errorMessage = String.format(
                "Expected socialSignInProvider to be <null> but was <%s>",
                actual.getSocialSignInProvider()
        );

        Assertions.assertThat(actual.getSocialSignInProvider())
                .overridingErrorMessage(errorMessage)
                .isNull();

        return this;
    }

    public ExampleUserDetailsAssert isSignedInByUsingSocialSignInProvider(SocialMediaService socialSignInProvider) {
        isNotNull();

        String errorMessage = String.format(
                "Expected socialSignInProvider to be <%s> but was <%s>",
                socialSignInProvider,
                actual.getSocialSignInProvider()
        );

        Assertions.assertThat(actual.getSocialSignInProvider())
                .overridingErrorMessage(errorMessage)
                .isEqualTo(socialSignInProvider);

        return this;
    }
}

创建SecurityContextAssert类

我们可以按照以下步骤创建第二个客户断言类:

  1. 创建一个SecurityContextAssert类,该类扩展了GenericAssert类。 提供以下类型参数:
    1. 第一个类型参数是自定义断言的类型。 将此类型参数的值设置为SecurityContextAssert
    2. 第二个类型参数是实际值对象的类型。 将此类型参数的值设置为SecurityContext
  2. 将私有构造函数添加到创建的类。 该构造函数将SecurityContext对象作为构造函数参数。 通过调用超类的构造函数并传递以下对象作为构造函数参数来实现控制器:
    1. 第一个构造函数参数是一个Class对象,它指定自定义断言类的类型。 将此构造函数参数的值设置为SecurityContextAssert.class
    2. 第二个构造函数参数是实际值对象。 将作为构造函数参数给出的对象传递给超类的构造函数。
  3. 将静态assertThat()方法添加到创建的类。 此方法将SecurityContext对象作为方法参数。 通过创建一个新的SecurityContextAssert对象来实现此方法。
  4. userIsAnonymous()方法添加到SecurityContextAssert类,并通过以下步骤实现它:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的SecurityContext对象不为null。
    2. SecurityContext获取Authentication对象,并确保它为null
    3. 返回对SecurityContextAssert对象的引用。
  5. 将一个loggingInUserIs()方法添加到SecurityContextAssert类。 此方法将User对象作为方法参数,并返回SecurityContextAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的SecurityContext对象不为null。
    2. SecurityContext获取ExampleUserDetails对象,并确保它不为null。
    3. 确保ExampleUserDetails对象的信息等于User对象的信息。
    4. 返回对SecurityContextAssert对象的引用。
  6. 将一个loggingInUserHasPassword()方法添加到SecurityContextAssert类。 此方法将String对象作为方法参数,并返回SecurityContextAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的SecurityContext对象不为null。
    2. SecurityContext获取ExampleUserDetails对象,并确保它不为null。
    3. 确保ExampleUserDetails对象的密码字段等于作为方法参数给出的密码。
    4. 返回对SecurityContextAssert对象的引用。
  7. LoggedInUserIsRegisteredByUsingNormalRegistration()方法添加到SecurityContextAssert类,并通过以下步骤实现它:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的SecurityContext对象不为null。
    2. SecurityContext获取ExampleUserDetails对象,并确保它不为null。
    3. 确保使用普通注册创建用户帐户。
    4. 返回对SecurityContextAssert对象的引用。
  8. 将一个loggingInUserIsSignedInByUsingSocialProvider()方法添加到SecurityContextAssert类。 此方法将SocialMediaService枚举作为方法参数,并返回SecurityContextAssert对象。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的SecurityContext对象不为null。
    2. SecurityContext获取ExampleUserDetails对象,并确保它不为null。
    3. 确保通过使用作为方法参数给出的SociaMediaService创建用户帐户。
    4. 返回对SecurityContextAssert对象的引用。

SecurityContextAssert类的源代码如下所示:

import org.fest.assertions.Assertions;
import org.fest.assertions.GenericAssert;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;

public class SecurityContextAssert extends GenericAssert<SecurityContextAssert, SecurityContext> {

    private SecurityContextAssert(SecurityContext actual) {
        super(SecurityContextAssert.class, actual);
    }

    public static SecurityContextAssert assertThat(SecurityContext actual) {
        return new SecurityContextAssert(actual);
    }

    public SecurityContextAssert userIsAnonymous() {
        isNotNull();

        Authentication authentication = actual.getAuthentication();

        String errorMessage = String.format("Expected authentication to be <null> but was <%s>.", authentication);
        Assertions.assertThat(authentication)
                .overridingErrorMessage(errorMessage)
                .isNull();

        return this;
    }

    public SecurityContextAssert loggedInUserIs(User user) {
        isNotNull();

        ExampleUserDetails loggedIn = (ExampleUserDetails) actual.getAuthentication().getPrincipal();

        String errorMessage = String.format("Expected logged in user to be <%s> but was <null>", user);
        Assertions.assertThat(loggedIn)
                .overridingErrorMessage(errorMessage)
                .isNotNull();

        ExampleUserDetailsAssert.assertThat(loggedIn)
                .hasFirstName(user.getFirstName())
                .hasId(user.getId())
                .hasLastName(user.getLastName())
                .hasUsername(user.getEmail())
                .isActive()
                .isRegisteredUser();

        return this;
    }

    public SecurityContextAssert loggedInUserHasPassword(String password) {
        isNotNull();

        ExampleUserDetails loggedIn = (ExampleUserDetails) actual.getAuthentication().getPrincipal();

        String errorMessage = String.format("Expected logged in user to be <not null> but was <null>");
        Assertions.assertThat(loggedIn)
                .overridingErrorMessage(errorMessage)
                .isNotNull();

        ExampleUserDetailsAssert.assertThat(loggedIn)
                .hasPassword(password);

        return this;
    }

    public SecurityContextAssert loggedInUserIsRegisteredByUsingNormalRegistration() {
        isNotNull();

        ExampleUserDetails loggedIn = (ExampleUserDetails) actual.getAuthentication().getPrincipal();

        String errorMessage = String.format("Expected logged in user to be <not null> but was <null>");
        Assertions.assertThat(loggedIn)
                .overridingErrorMessage(errorMessage)
                .isNotNull();

        ExampleUserDetailsAssert.assertThat(loggedIn)
                .isRegisteredByUsingFormRegistration();

        return this;
    }

    public SecurityContextAssert loggedInUserIsSignedInByUsingSocialProvider(SocialMediaService signInProvider) {
        isNotNull();

        ExampleUserDetails loggedIn = (ExampleUserDetails) actual.getAuthentication().getPrincipal();

        String errorMessage = String.format("Expected logged in user to be <not null> but was <null>");
        Assertions.assertThat(loggedIn)
                .overridingErrorMessage(errorMessage)
                .isNotNull();

        ExampleUserDetailsAssert.assertThat(loggedIn)
                .hasPassword("SocialUser")
                .isSignedInByUsingSocialSignInProvider(signInProvider);

        return this;
    }
}

创建TestProviderSignInAttemptAssert类

我们可以按照以下步骤创建第三个自定义断言类:

  1. 创建一个TestProviderSignInAttemptAssert类,该类扩展了GenericAssert类。 提供以下类型参数:
    1. 第一个类型参数是自定义断言的类型。 将此类型参数的值设置为TestProviderSignInAttemptAssert
    2. 第二个类型参数是实际值对象的类型。 将此类型参数的值设置为TestProviderSignInAttempt
  2. 将私有构造函数添加到创建的类。 该构造函数将TestProviderSignInAttempt对象作为构造函数参数。 通过调用超类的构造函数并传递以下对象作为构造函数参数来实现控制器:
    1. 第一个构造函数参数是一个Class对象,它指定自定义断言类的类型。 将此构造函数参数的值设置为TestProviderSignInAttemptAssert.class
    2. 第二个构造函数参数是实际值对象。 将作为构造函数参数给出的对象传递给超类的构造函数。
  3. 将静态assertThatSignIn()方法添加到创建的类。 此方法将TestProviderSignInAttempt对象作为方法参数。 通过创建一个新的TestProviderSignInAttemptAssert对象来实现此方法。
  4. 在创建的类中添加一个createdNoConnections()方法。 此方法不带方法参数,并且返回对TestProviderSignInAttemptAssert对象的引用。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的TestProviderSignInAttempt对象不为null。
    2. 确保实际的TestProviderSignInAttempt对象未创建任何连接。
    3. 返回对TestProviderSignInAttemptAssert对象的引用。
  5. 向创建的类添加一个createdConnectionForUserId()方法。 此方法将String对象作为方法参数,并返回对TestProviderSignInAttempt对象的引用。 我们可以通过执行以下步骤来实现此方法:
    1. 通过调用GenericAssert类的isNotNull()方法,确保实际的TestProviderSignInAttempt对象不为null。
    2. 确保为用户ID为方法参数的用户创建了连接。
    3. 返回对TestProviderSignInAttemptAssert对象的引用。

TestProviderSignInAttemptAssert类的源代码如下所示:

import org.fest.assertions.Assertions;
import org.fest.assertions.GenericAssert;
import org.springframework.social.connect.web.TestProviderSignInAttempt;

public class TestProviderSignInAttemptAssert extends GenericAssert<TestProviderSignInAttemptAssert, TestProviderSignInAttempt> {

    private TestProviderSignInAttemptAssert(TestProviderSignInAttempt actual) {
        super(TestProviderSignInAttemptAssert.class, actual);
    }

    public static TestProviderSignInAttemptAssert assertThatSignIn(TestProviderSignInAttempt actual) {
        return new TestProviderSignInAttemptAssert(actual);
    }

    public TestProviderSignInAttemptAssert createdNoConnections() {
        isNotNull();

        String error = String.format(
                "Expected that no connections were created but found <%d> connection",
                actual.getConnections().size()
        );
        Assertions.assertThat(actual.getConnections())
                .overridingErrorMessage(error)
                .isEmpty();

        return this;
    }

    public TestProviderSignInAttemptAssert createdConnectionForUserId(String userId) {
        isNotNull();

        String error = String.format(
                "Expected that connection was created for user id <%s> but found none.",
                userId
        );

        Assertions.assertThat(actual.getConnections())
                .overridingErrorMessage(error)
                .contains(userId);

        return this;
    }
}

让我们继续并开始为RegistrationController类编写一些单元测试。

写作单元测试

现在,我们已经完成准备工作,并准备为注册功能编写单元测试。 我们必须为以下控制器方法编写单元测试:

  • 第一种控制器方法呈现注册页面。
  • 第二种控制器方法处理注册表格的提交。

在开始编写单元测试之前,我们必须对其进行配置。 让我们找出这是如何完成的。

注意:我们的单元测试使用Spring MVC测试框架。 如果您不熟悉它,建议您看一下我的Spring MVC Test教程

配置我们的单元测试

我们的示例应用程序应用程序上下文配置以一种易于编写Web层的单元测试的方式进行设计。 这些设计原理如下所述:

  • 应用程序上下文配置分为几个配置类,每个类都配置了应用程序的特定部分(Web,安全性,社交性和持久性)。
  • 我们的应用程序上下文配置有一个“主”配置类,该类配置一些“通用” bean并导入其他配置类。 该配置类还为服务层配置组件扫描。

当我们遵循这些原则配置应用程序上下文时,很容易为我们的单元测试创​​建应用程序上下文配置。 我们可以通过重新使用配置示例应用程序的Web层的应用程序上下文配置类并为单元测试创​​建一个新的应用程序上下文配置类来做到这一点。

通过执行以下步骤,我们可以为单元测试创​​建应用程序上下文配置类:

  1. 创建一个名为UnitTestContext的类。
  2. @Configuration批注注释创建的类。
  3. 向创建的类中添加messageSource()方法,并使用@Bean注释对方法进行注释。 通过执行以下步骤配置MessageSource bean:
    1. 创建一个新的ResourceBundleMessageSource对象。
    2. 设置消息文件的基本名称,并确保如果未找到消息,则返回其代码。
    3. 返回创建的对象。
  4. userService()方法添加到创建的类中,并使用@Bean批注对方法进行批注。 通过执行以下步骤配置UserService模拟对象:
    1. 调用Mockito类的静态嘲笑()方法,并将UserService.class作为方法参数传递。
    2. 返回创建的对象。

UnitTestContext类的源代码如下所示:

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;

import static org.mockito.Mockito.mock;

@Configuration
public class UnitTestContext {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();

        messageSource.setBasename("i18n/messages");
        messageSource.setUseCodeAsDefaultMessage(true);

        return messageSource;
    }

    @Bean
    public UserService userService() {
        return mock(UserService.class);
    }
}

接下来要做的是配置单元测试。 我们可以按照以下步骤进行操作:

  1. @RunWith注释对测试类进行注释,并确保使用SpringUnit4ClassRunner执行我们的测试。
  2. @ContextConfiguration批注对类进行批注,并确保使用正确的配置类。 在我们的例子中,正确的配置类是: WebAppContextUnitTestContext
  3. @WebAppConfiguration批注对类进行批注。 此批注确保加载的应用程序上下文是WebApplicationContext
  4. MockMvc字段添加到测试类。
  5. WebApplicationContext字段添加到类中,并使用@Autowired批注对其进行批注。
  6. UserService字段添加到测试类,并使用@Autowired批注对其进行批注。
  7. setUp()方法添加到测试类,并使用@Before注释对方法进行注释。 这样可以确保在每个测试方法之前调用该方法。 通过执行以下步骤来实现此方法:
    1. 通过调用Mockito静态reset()方法并将经过重置的模拟作为方法参数传递来重置UserService模拟。
    2. 通过使用MockMvcBuilders类创建一个新的MockMvc对象。
    3. 运行我们的测试时,请确保从SecurityContext中没有找到Authentication对象。 我们可以按照以下步骤进行操作:
      1. 通过调用SecurityContextHolder静态getContext()方法来获取对SecurityContext对象的引用。
      2. 通过调用SecurityContextsetAuthentication()方法清除身份验证。 将null作为方法参数传递。

我们的单元测试类的源代码如下所示:

import org.junit.Before;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest2 {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    @Before
    public void setUp() {
        Mockito.reset(userServiceMock);

        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
                .build();
               
        SecurityContextHolder.getContext().setAuthentication(null);
    }
}

注意:如果要获取有关使用Spring MVC Test框架的单元测试的配置的更多信息,建议您阅读此博客文章

让我们继续并为呈现注册表格的控制器方法编写单元测试。

提交注册表

呈现注册表控制器方法具有一项重要职责:

如果用户正在使用社交登录,则使用由使用过的SaaS API提供程序提供的使用信息来预填充注册字段。

让我们刷新内存,看一下RegistrationController类的源代码:

import org.springframework.social.connect.Connection;
import org.springframework.social.connect.ConnectionKey;
import org.springframework.social.connect.UserProfile;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.context.request.WebRequest;

@Controller
@SessionAttributes("user")
public class RegistrationController {

    @RequestMapping(value = "/user/register", method = RequestMethod.GET)
    public String showRegistrationForm(WebRequest request, Model model) {
        Connection<?> connection = ProviderSignInUtils.getConnection(request);

        RegistrationForm registration = createRegistrationDTO(connection);
        model.addAttribute("user", registration);

        return "user/registrationForm";
    }

    private RegistrationForm createRegistrationDTO(Connection<?> connection) {
        RegistrationForm dto = new RegistrationForm();

        if (connection != null) {
            UserProfile socialMediaProfile = connection.fetchUserProfile();
            dto.setEmail(socialMediaProfile.getEmail());
            dto.setFirstName(socialMediaProfile.getFirstName());
            dto.setLastName(socialMediaProfile.getLastName());

            ConnectionKey providerKey = connection.getKey();
            dto.setSignInProvider(SocialMediaService.valueOf(providerKey.getProviderId().toUpperCase()));
        }

        return dto;
    }
}

显然,我们必须为此控制器方法编写两个单元测试:

  1. 我们必须编写一个测试,以确保当用户使用“常规”注册时,控制器方法能够正常工作。
  2. 我们必须编写一个测试,以确保当用户使用社交登录时,控制器方法能够正常工作。

让我们移动并编写这些单元测试。

测试1:提​​交普通注册表

我们可以按照以下步骤编写第一个单元测试:

  1. 执行GET请求以发送url'/ user / register'。
  2. 确保返回HTTP状态代码200。
  3. Verify that the name of the rendered view is 'user/registrationForm'.
  4. Verify that the request is forwarded to url '/WEB-INF/jsp/user/registrationForm.jsp'.
  5. Ensure that all fields of the model attribute called 'user' are either null or empty.
  6. Verify that no methods of the UserService mock were called.

The source code of our unit test looks as follows:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    //The setUp() method is omitted for the sake of clarity

    @Test
    public void showRegistrationForm_NormalRegistration_ShouldRenderRegistrationPageWithEmptyForm() throws Exception {
        mockMvc.perform(get("/user/register"))
                .andExpect(status().isOk())
                .andExpect(view().name("user/registrationForm"))
                .andExpect(forwardedUrl("/WEB-INF/jsp/user/registrationForm.jsp"))
                .andExpect(model().attribute("user", allOf(
                        hasProperty("email", isEmptyOrNullString()),
                        hasProperty("firstName", isEmptyOrNullString()),
                        hasProperty("lastName", isEmptyOrNullString()),
                        hasProperty("password", isEmptyOrNullString()),
                        hasProperty("passwordVerification", isEmptyOrNullString()),
                        hasProperty("signInProvider", isEmptyOrNullString())
                )));

        verifyZeroInteractions(userServiceMock);
    }
}

Test 2: Rendering the Registration Form by Using Social Sign In

We can write the second unit test by following these steps:

  1. Create a new TestProviderSignInAttempt object by using the TestProviderSignInAttemptBuilder class. Set the provider id, first name, last name and email address.
  2. Execute a GET request to url '/user/register' and set the created TestProviderSignInAttempt object to the HTTP session.
  3. Ensure that the HTTP status code 200 is returned.
  4. Verify that the name of the rendered view is 'user/registrationForm'.
  5. Ensure that the request is forwarded to url '/WEB-INF/jsp/user/registrationForm.jsp'.
  6. Verify that the fields of the model object called 'user' are pre-populated by using the information contained by the TestProviderSignInAttempt object. 我们可以按照以下步骤进行操作:
    1. Ensure that the value of the email field is 'john.smith@gmail.com'.
    2. Ensure that the value of the firstName field is 'John'.
    3. Ensure that the value of the lastName field is 'Smith'.
    4. Ensure that the value of the password field is empty or null String.
    5. Ensure that the value of the passwordVerification field is empty or null String.
    6. Ensure that the value of the signInProvider field is 'twitter'.
  7. Verify that the methods of the UserService interface were not called.

The source code of our unit test looks as follows:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.support.TestProviderSignInAttemptBuilder;
import org.springframework.social.connect.web.ProviderSignInAttempt;
import org.springframework.social.connect.web.TestProviderSignInAttempt;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    //The setUp() method is omitted for the sake of clarity

    @Test
    public void showRegistrationForm_SocialSignInWithAllValues_ShouldRenderRegistrationPageWithAllValuesSet() throws Exception {
        TestProviderSignInAttempt socialSignIn = new TestProviderSignInAttemptBuilder()
                .connectionData()
                    .providerId("twitter")
                .userProfile()
                    .email("john.smith@gmail.com")
                    .firstName("John")
                    .lastName("Smith")
                .build();

        mockMvc.perform(get("/user/register")
                .sessionAttr(ProviderSignInAttempt.SESSION_ATTRIBUTE, socialSignIn)
        )
                .andExpect(status().isOk())
                .andExpect(view().name("user/registrationForm"))
                .andExpect(forwardedUrl("/WEB-INF/jsp/user/registrationForm.jsp"))
                .andExpect(model().attribute("user", allOf(
                        hasProperty("email", is("john.smith@gmail.com")),
                        hasProperty("firstName", is("John")),
                        hasProperty("lastName", is("Smith")),
                        hasProperty("password", isEmptyOrNullString()),
                        hasProperty("passwordVerification", isEmptyOrNullString()),
                        hasProperty("signInProvider", is("twitter"))
                )));

        verifyZeroInteractions(userServiceMock);
    }
}

Submitting The Registration Form

The controller method which processes the submissions of the registration form has the following responsibilities:

  1. It validates the information entered to the registration form. If the information is not valid, it renders the registration form and shows validation error messages to user.
  2. If the email address given by the user is not unique, it renders the registration form and shows an error message to the user.
  3. It creates a new user account by using the UserService interface and logs the created user in.
  4. It persists the connection to a SaaS API provider if user was using social sign in
  5. It redirects user to the front page.

The relevant part of the RegistrationController class looks as follows:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.ProviderSignInUtils;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.context.request.WebRequest;

import javax.validation.Valid;

@Controller
@SessionAttributes("user")
public class RegistrationController {

    private UserService service;

    @Autowired
    public RegistrationController(UserService service) {
        this.service = service;
    }

    @RequestMapping(value ="/user/register", method = RequestMethod.POST)
    public String registerUserAccount(@Valid @ModelAttribute("user") RegistrationForm userAccountData,
                                      BindingResult result,
                                      WebRequest request) throws DuplicateEmailException {
        if (result.hasErrors()) {
            return "user/registrationForm";
        }

        User registered = createUserAccount(userAccountData, result);

        if (registered == null) {
            return "user/registrationForm";
        }
        SecurityUtil.logInUser(registered);
        ProviderSignInUtils.handlePostSignUp(registered.getEmail(), request);

        return "redirect:/";
    }

    private User createUserAccount(RegistrationForm userAccountData, BindingResult result) {
        User registered = null;

        try {
            registered = service.registerNewUserAccount(userAccountData);
        }
        catch (DuplicateEmailException ex) {
            addFieldError(
                    "user",
                    "email",
                    userAccountData.getEmail(),
                    "NotExist.user.email",
                    result);
        }

        return registered;
    }

    private void addFieldError(String objectName, String fieldName, String fieldValue,  String errorCode, BindingResult result) {
        FieldError error = new FieldError(
                objectName,
                fieldName,
                fieldValue,
                false,
                new String[]{errorCode},
                new Object[]{},
                errorCode
        );

        result.addError(error);
    }
}

We will write three unit tests for this controller method:

  1. We write a unit test which ensures that the controller method is working properly when validation fails.
  2. We write a unit test which ensures the the controller method is working when the email address isn't unique.
  3. We write a unit test which ensures that the controller method is working properly when the registration is successful.

Let's find out how we can write these unit tests.

测试1:验证失败

We can write the first unit test by following these steps:

  1. Create a new TestProviderSignInAttempt object by using the TestProviderSignInAttemptBuilder class. Set the provider id, first name, last name and email address.
  2. Create a new RegistrationForm object by using the RegistrationFormBuilder class. Set the value of the signInProvider field.
  3. Execute a POST request to url '/user/register' by following these steps:
    1. 将请求的内容类型设置为“ application / x-www-form-urlencoded”。
    2. Convert the form object into url encoded bytes and set the outcome of the conversion into the body of the request.
    3. Set the created TestProviderSignInAttempt object to the HTTP session.
    4. Set the form object to the HTTP session.
  4. Verify that the HTTP status code 200 is returned.
  5. 确保渲染视图的名称为“ user / registrationForm”。
  6. Ensure that the request is forwarded to url '/WEB-INF/jsp/user/registrationForm.jsp'.
  7. Verify that field values of the model object called 'user' are correct by following these steps:
    1. Verify that the value of the email field is empty or null String.
    2. Verify that the value of the firstName field is empty or null String.
    3. Verify that the value of the lastName field is empty or null String.
    4. Verify that the value of the password field is empty or null String.
    5. Verify that the value of the passwordVerification field is empty or null String.
    6. Verify that the value of the signInProvider field is 'twitter'.
  8. Ensure that the model attribute called 'user' has field errors in email , firstName , and lastName fields.
  9. Verify that the current user is not logged in.
  10. Ensure that no connections were created by using the TestProviderSignInAttempt object.
  11. Verify that the methods of the UserService mock were not called.

The source code of our unit test looks as follows:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.support.TestProviderSignInAttemptBuilder;
import org.springframework.social.connect.web.ProviderSignInAttempt;
import org.springframework.social.connect.web.TestProviderSignInAttempt;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    //The setUp() method is omitted for the sake of clarity

    @Test
    public void registerUserAccount_SocialSignInAndEmptyForm_ShouldRenderRegistrationFormWithValidationErrors() throws Exception {
        TestProviderSignInAttempt socialSignIn = new TestProviderSignInAttemptBuilder()
                .connectionData()
                    .providerId("twitter")
                .userProfile()
                    .email("john.smith@gmail.com")
                    .firstName("John")
                    .lastName("Smith")
                .build();

        RegistrationForm userAccountData = new RegistrationFormBuilder()
                .signInProvider(SocialMediaService.TWITTER)
                .build();

        mockMvc.perform(post("/user/register")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .content(TestUtil.convertObjectToFormUrlEncodedBytes(userAccountData))
                .sessionAttr(ProviderSignInAttempt.SESSION_ATTRIBUTE, socialSignIn)
                .sessionAttr("user", userAccountData)
        )
                .andExpect(status().isOk())
                .andExpect(view().name("user/registrationForm"))
                .andExpect(forwardedUrl("/WEB-INF/jsp/user/registrationForm.jsp"))
                .andExpect(model().attribute("user", allOf(
                        hasProperty("email", isEmptyOrNullString()),
                        hasProperty("firstName", isEmptyOrNullString()),
                        hasProperty("lastName", isEmptyOrNullString()),
                        hasProperty("password", isEmptyOrNullString()),
                        hasProperty("passwordVerification", isEmptyOrNullString()),
                        hasProperty("signInProvider", is(SocialMediaService.TWITTER))
                )))
                .andExpect(model().attributeHasFieldErrors("user", "email", "firstName", "lastName"));

        assertThat(SecurityContextHolder.getContext()).userIsAnonymous();
        assertThatSignIn(socialSignIn).createdNoConnections();
        verifyZeroInteractions(userServiceMock);
    }
}

测试2:从数据库中找到电子邮件地址

We can write the second unit test by following these steps:

  1. Create a new TestProviderSignInAttempt object by using the TestProviderSignInAttemptBuilder class. Set the provider id, first name, last name and email address.
  2. Create a new RegistrationForm object by using the RegistrationFormBuilder class. Set the values of email , firstName , lastName , and signInProvider fields.
  3. Configure the UserService mock to throw a DuplicateEmailException when its registerNewUserAccount() method is called and the form object is given as a method parameter.
  4. Execute a POST request to url '/user/register' by following these steps:
    1. 将请求的内容类型设置为“ application / x-www-form-urlencoded”。
    2. Convert the form object into url encoded bytes and set the outcome of the conversion into the body of the request.
    3. Set the created TestProviderSignInAttempt object to the HTTP session.
    4. Set the form object to the HTTP session.
  5. Verify that the HTTP status code 200 is returned.
  6. 确保渲染视图的名称为“ user / registrationForm”。
  7. Ensure that the request is forwarded to url '/WEB-INF/jsp/user/registrationForm.jsp'.
  8. Verify that field values of the model object called 'user' are correct by following these steps:
    1. Ensure that the value of the email field is 'john.smith@gmail.com'.
    2. Ensure that the value of the firstName field is 'John'.
    3. Ensure that the value of the lastName field is 'Smith'.
    4. Ensure that the value of the password field is empty or null String.
    5. Ensure that the value of the passwordVerification field is empty or null String.
    6. Ensure that the value of the signInProvider field is 'twitter'.
  9. Ensure that the model attribute called 'user' has field error in email field.
  10. Verify that the current user is not logged in.
  11. Ensure that no connections were created by using the TestProviderSignInAttempt object.
  12. Verify that the registerNewUserAccount() method of the UserService mock was called once and that the RegistrationForm object was given as a method parameter.
  13. Verify that the other methods of the UserService interface weren't invoked during the test.

The source code of our unit test looks as follows:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.support.TestProviderSignInAttemptBuilder;
import org.springframework.social.connect.web.ProviderSignInAttempt;
import org.springframework.social.connect.web.TestProviderSignInAttempt;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isEmptyOrNullString;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    //The setUp() method is omitted for the sake of clarity.

    @Test
    public void registerUserAccount_SocialSignInAndEmailExist_ShouldRenderRegistrationFormWithFieldError() throws Exception {
        TestProviderSignInAttempt socialSignIn = new TestProviderSignInAttemptBuilder()
                .connectionData()
                    .providerId("twitter")
                .userProfile()
                    .email("john.smith@gmail.com")
                    .firstName("John")
                    .lastName("Smith")
                .build();

        RegistrationForm userAccountData = new RegistrationFormBuilder()
                .email("john.smith@gmail.com")
                .firstName("John")
                .lastName("Smith")
                .signInProvider(SocialMediaService.TWITTER)
                .build();

        when(userServiceMock.registerNewUserAccount(userAccountData)).thenThrow(new DuplicateEmailException(""));

        mockMvc.perform(post("/user/register")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .content(TestUtil.convertObjectToFormUrlEncodedBytes(userAccountData))
                .sessionAttr(ProviderSignInAttempt.SESSION_ATTRIBUTE, socialSignIn)
                .sessionAttr("user", userAccountData)
        )
                .andExpect(status().isOk())
                .andExpect(view().name("user/registrationForm"))
                .andExpect(forwardedUrl("/WEB-INF/jsp/user/registrationForm.jsp"))
                .andExpect(model().attribute("user", allOf(
                        hasProperty("email", is("john.smith@gmail.com")),
                        hasProperty("firstName", is("John")),
                        hasProperty("lastName", is("Smith")),
                        hasProperty("password", isEmptyOrNullString()),
                        hasProperty("passwordVerification", isEmptyOrNullString()),
                        hasProperty("signInProvider", is(SocialMediaService.TWITTER))
                )))
                .andExpect(model().attributeHasFieldErrors("user", "email"));

        assertThat(SecurityContextHolder.getContext()).userIsAnonymous();
        assertThatSignIn(socialSignIn).createdNoConnections();

        verify(userServiceMock, times(1)).registerNewUserAccount(userAccountData);
        verifyNoMoreInteractions(userServiceMock);
    }
}

测试3:注册成功

We can write the third unit test by following these steps:

  1. Create a new TestProviderSignInAttempt object by using the TestProviderSignInAttemptBuilder class. Set the provider id, first name, last name and email address.
  2. Create a new RegistrationForm object by using the RegistrationFormBuilder class. Set the values of email , firstName , lastName , and signInProvider fields.
  3. Create a new User object by using the UserBuilder class. Set the values of id , email , firstName , lastName , and signInProvider fields.
  4. Configure the UserService mock object to return the created User object when its registerNewUserAccount() method is called and the RegistrationForm object is given as a method parameter.
  5. Execute a POST request to url '/user/register' by following these steps:
    1. 将请求的内容类型设置为“ application / x-www-form-urlencoded”。
    2. Convert the form object into url encoded bytes and set the outcome of the conversion into the body of the request.
    3. Set the created TestProviderSignInAttempt object to the HTTP session.
    4. Set the form object to the HTTP session.
  6. Verify that the HTTP status code 302 is returned.
  7. Ensure that the request is redirected to url '/'.
  8. Verify that the created user is logged in by using Twitter.
  9. Verify that the TestProviderSignInAttempt object was used to created a connection for a user with email address 'john.smith@gmail.com'.
  10. Verify that the registerNewUserAccount() method of the UserService mock was called once and that the form object was given as a method parameter.
  11. Verify that the other methods of the UserService mock weren't invoked during the test.

The source code of our unit test looks as follows:

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.social.connect.support.TestProviderSignInAttemptBuilder;
import org.springframework.social.connect.web.ProviderSignInAttempt;
import org.springframework.social.connect.web.TestProviderSignInAttempt;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {WebAppContext.class, UnitTestContext.class})
@WebAppConfiguration
public class RegistrationControllerTest {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webAppContext;

    @Autowired
    private UserService userServiceMock;

    //The setUp() method is omitted for the sake of clarity.

    @Test
    public void registerUserAccount_SocialSignIn_ShouldCreateNewUserAccountAndRenderHomePage() throws Exception {
        TestProviderSignInAttempt socialSignIn = new TestProviderSignInAttemptBuilder()
                .connectionData()
                    .providerId("twitter")
                .userProfile()
                    .email("john.smith@gmail.com")
                    .firstName("John")
                    .lastName("Smith")
                .build();

        RegistrationForm userAccountData = new RegistrationFormBuilder()
                .email("john.smith@gmail.com")
                .firstName("John")
                .lastName("Smith")
                .signInProvider(SocialMediaService.TWITTER)
                .build();

        User registered = new UserBuilder()
                .id(1L)
                .email("john.smith@gmail.com")
                .firstName("John")
                .lastName("Smith")
                .signInProvider(SocialMediaService.TWITTER)
                .build();

        when(userServiceMock.registerNewUserAccount(userAccountData)).thenReturn(registered);

        mockMvc.perform(post("/user/register")
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .content(TestUtil.convertObjectToFormUrlEncodedBytes(userAccountData))
                .sessionAttr(ProviderSignInAttempt.SESSION_ATTRIBUTE, socialSignIn)
                .sessionAttr("user", userAccountData)
        )
                .andExpect(status().isMovedTemporarily())
                .andExpect(redirectedUrl("/"));

        assertThat(SecurityContextHolder.getContext())
                .loggedInUserIs(registered)
                .loggedInUserIsSignedInByUsingSocialProvider(SocialMediaService.TWITTER);
        assertThatSignIn(socialSignIn).createdConnectionForUserId("john.smith@gmail.com");

        verify(userServiceMock, times(1)).registerNewUserAccount(userAccountData);
        verifyNoMoreInteractions(userServiceMock);
    }
}

概要

We have now written some unit tests for the registration function of our example application. 这篇博客文章教会了我们四件事:

  1. We learned how we can create the test doubles required by our unit tests.
  2. We learned to emulate social sign in by using the created test double classes.
  3. We learned how we can verify that the connection to the used SaaS API provider is persisted after a new user account has been created for a user who used social sign in.
  4. We learned how we can verify that the user is logged in after a new user account has been created.

The example application of this blog post has many tests which were not covered in this blog post. If you are interested to see them, you can get the example application from Github .

PS This blog post describes one possible approach for writing unit tests to a registration controller which uses Spring Social 1.1.0. If you have any improvement ideas, questions, or feedback about my approach, feel free to leave a comment to this blog post.

翻译自: https://www.javacodegeeks.com/2013/12/adding-social-sign-in-to-a-spring-mvc-web-application-unit-testing.html

spring

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值