单元测试理论储备及JUnit5实战

概述

测试驱动开发,TDD,Test Driven Development,优点:

  1. 使得开发人员对即将编写的软件任务具有更清晰的认识,使得他们在思考如何编写代码之前先仔细思考如何设计软件
  2. 对测试开发人员所实现的代码提供快速和自动化的支持;
  3. 提供一系列可以重用的回归测试用例(regression test case),这些测试用例可以用来检测未来添加的新代码是否改变以前系统定义的行为(测试代码兼容性)。

TDD 也有局限性。测试代码的表达的局限性,有限的测试代码根本不能覆盖所有的代码行为。

理论Theory机制

理论的出现就是为了解决 TDD 这个问题。TDD 为组织规划开发流程提供一个方法,先用一些具体的例子(测试用例 test case)来描述系统代码的行为,然后再将这些行为用代码语句进行概括性的总的陈述(代码实现 implementation)。而 Theory 就是对传统的 TDD 进行一个延伸和扩展,它使得开发人员从开始的定义测试用例的阶段就可以通过参数集(理论上是无限个参数)对代码行为进行概括性的总的陈述,我们叫这些陈述为理论。理论就是对那些需要无穷个测试用例才能正确描述的代码行为的概括性陈述。结合理论和测试一起,可以轻松的描述代码的行为并发现 BUG 。开发人员都知道他们代码所想要实现的概括性的总的目的,理论使得他们只需要在一个地方就可以快速的指定这些目的,而不要将这些目的翻译成大量的独立的测试用例。

理论机制的优点:

  1. 理论使得开发完全抽象的接口更加容易。
  2. 理论仍然可以重用以前的测试用例,因为以前的许多传统的具体的测试用例仍然可以被轻松的改写成理论测试实例。
  3. 理论可以测试出一些原本测试用例没测出来的 bugs 。
  4. 理论允许配合自动化测试工具进行使用,自动化工具通过大量的数据点来测试一个理论,从而可以放大增强理论的效果。利用自动化工具来分析代码,找出可以证明理论错误的值。

Junit5

JUnit 5完全使用Java 8重写所有代码,JUnit 5允许在断言中使用Lambda表达式,可以从断言库AssertJ中可以看到。与JUnit 4不同,JUnit 5不再是单个库,而是模块化结构的集合,包括:自己的模块、引擎、launcher、针对Gradle和Surefire的集成模块JUnit团队还发起名为Open Test Alliance for the JVM的活动,OpenTest4j。JUnit 5还提供全新的一套注解集合,断言方法从JUnit 4的org.junit.Assert包移到JUnit 5的org.junit.gen5.api.Assertions包。

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

  • JUnit Platform是在JVM上启动测试框架的基础
  • JUnit Jupiter是JUnit5扩展的新的编程模型和扩展模型,用来编写测试用例。Jupiter子项目为在平台上运行Jupiter的测试提供了一个TestEngine (测试引擎)
  • JUnit Vintage提供一个在平台上运行JUnit 3和JUnit 4的TestEngine
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

实际上,在maven搜索junit时,如果要使用junit5,会指引使用新的artifactId:

Note: This artifact was moved to: 
org.junit.jupiter » junit-jupiter-api

不过,可以直接使用下面这个aggregator:
在这里插入图片描述
打开junit-jupiter的pom文件,也知道junit-jupiter是三个artifactId的汇总:

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-api</artifactId>
  <scope>compile</scope>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-params</artifactId>
  <scope>compile</scope>
</dependency>
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-engine</artifactId>
  <scope>runtime</scope>
</dependency>

JUnit5优势

  • 支持lambda表达式,支持lambda表达式
  • JUnit5易扩展,包容性强,可以接入其他的测试引擎
  • 提供新的断言机制、参数化测试、重复性测试等新功能

JUnit5结构:

  • JUnit Platform:这是Junit提供的平台功能模块,通过它,其它的测试引擎都可以接入Junit实现接口和执行
  • JUnit JUpiter:这是JUnit5的核心,是一个基于JUnit Platform的引擎实现,它包含许多丰富的新特性来使得自动化测试更加方便和强大
  • JUnit Vintage:这个模块是兼容JUnit3、JUnit4版本的测试引擎,使得旧版本的自动化测试也可以在JUnit5下正常运行

实例

在JUnit 5中的一个测试类的生命周期基本是这样的:

class Lifecycle {
	@BeforeAll
	static void initializeExternalResources() {
	}
	@BeforeEach
	void initializeMockObjects() {
	}
	@Test
	void someTest() {
		assertTrue(true);
	}
	@Test
	void otherTest() {
		assumeTrue(true);
		assertNotEquals(1, 42, "Why wouldn't these be the same?");
	}
	@Test
	@Disabled
	void disabledTest() {
		System.exit(1);
	}
	@AfterEach
	void tearDown() {
	}
	@AfterAll
	static void freeExternalResources() {
	}
}

引入依赖,防止使用旧的junit4相关接口需将其排除

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.vintage</groupId>
            <artifactId>junit-vintage-engine</artifactId>
        </exclusion>
    </exclusions>
</dependency>

注解

@BeforeEach:在每个单元测试方法执行前都执行一遍
@BeforeAll:在每个单元测试方法执行前执行一遍(只执行一次)
@DisplayName(“商品入库测试”):用于指定单元测试的名称
@Disabled:当前单元测试置为无效,即单元测试时跳过该测试
@RepeatedTest(n):重复性测试,即执行n次
@ParameterizedTest:参数化测试
@ValueSource(ints = {1, 2, 3}):参数化测试提供数据

假设、标签和禁止测试

假设、标签和禁止测试是JUnit 4的特性,在JUnit 5中仍然得以保留。不同的是假设中也支持Lambda表达式,假设的思想是如果假设条件没有得到满足,那么跳过测试执行。标签Tags等同于JUnit 4的测试分类的概念,可以对测试类和方法进行分类。JUnit 4禁止测试使用@Ignore注释,而在JUnit 5中则使用@Disabled注释。

Assert断言

JUnit 5的断言方法与JUnit 4相似,断言类提供assertTrue、assertEquals、assertNull、assertSame以及相反的断言方法。不同之处在于JUnit 5的断言方法支持Lambda表达式,并提供分组断言(Grouped Assertions)的新特性。分组断言允许执行一组断言,且会一起报告。在JUnit 4中,在一个测试中放入多个断言时,如果前面的断言执行失败(即验证失败),后面的断言将得不到执行。JUnit 5中使用分组断言就没有这个问题。
对JUnit 4的另一个改进是断言预期的异常。不再是以前那种把预期的异常类型放入@Test注释,或者是用try-catch包裹代码,JUnit 5使用assertThrows和equalsThrows断言。

JUnit 5支持Hamcrest匹配和AssertJ断言库,可以用它们来代替JUnit 5的方法。

JUnit Jupiter提供强大的断言方法用以验证结果,在使用时需要借助java8的新特性lambda表达式,均是来自org.junit.jupiter.api.Assertions包的static方法:

  • assertTrue与assertFalse用来判断条件是否为true或false
  • assertNull与assertNotNull用来判断条件是否为null
  • assertThrows用来判断执行抛出的异常是否符合预期,并可以使用异常类型接收返回值进行其他操作
  • assertTimeout用来判断执行过程是否超时
@Test
@DisplayName("测试断言超时")
void testTimeOut() {
    String actualResult = assertTimeout(ofSeconds(2), () -> {
        Thread.sleep(1000);
        return "a result";
    });
    System.out.println(actualResult);
}

assertAll是组合断言,当它内部所有断言正确执行完才算通过

@Test
@DisplayName("测试组合断言")
void testAll() {
    assertAll("测试item商品下单",
            () -> {
                // 模拟用户余额扣减
                assertTrue(1 < 2, "余额不足");
            },
            () -> {
                // 模拟item数据库扣减库存
                assertTrue(3 < 4);
            },
            () -> {
                // 模拟交易流水落库
                assertNotNull(new Object());
            }
    );
}

扩展模型

JUnit 5提供一套新的扩展API,取代以前的@RunWith和@Rule扩展机制。JUnit 4的测试类被限制到仅有一个Runner上,而新的扩展模型则允许一个类或方法keyii注册到多种扩展。

JUnit 5内建的扩展还支持方法级的依赖注入。

@ExtendWith(MockitoExtension.class)
@ExtendWith(CdiUnitExtension.class)
public class Test4 {
	@Test
	@DisplayName("awesome test")
	void dependencyInjection(TestInfo testInfo) {
		assertEquals("awesome test", testInfo.getDisplayName());
	}
}

Rule

JUnit Rule就是实现TestRule的类,作用类似于@Before、@After,用来在每个测试方法的执行前后执行一些代码的一个方法。

@Before、@After、@AfterClass、@BeforeClass都只能作用于一个类,如果同一个setup需要在两个类里面同时使用,那么你就要在两个测试类里面定义相同的@Before方法,然后里面写相同的代码,这就造成代码重复。
解决方法:

  1. 创建TestClass,然后继承;
  2. 使用JUnit Rule。另外,它还能做一些@Before这些Annotation做不到的事情。

怎么用JUnit Rule?

  1. 使用框架自带的Rule
    JUnit、Mockito自带JUnit Rule,如Timeout,TemporaryFolder。定义类的public field,然后添加注解@Rule。
public class TimeoutTest {
    @Rule
    public Timeout timeout = new Timeout(1000);
    @Test
    public void testMethod() throws Exception {
        //your tests
    }
}

对于TimeoutTest的每一个测试方法,运行时间不能超过1秒,不然会标志为失败。

  1. 自定义Rule
    需要实现TestRule接口重写apply()方法,返回一个Statement对象。例子:在测试方法运行之前,记录测试方法所在的类名和方法名,并打印出来:
public class TestRuleDemo implements TestRule {
    @Override
    public Statement apply(final Statement base, final Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                // 在base.evaluate()之前加上自定义的动作
                String className = description.getClassName();
                String methodName = description.getMethodName();
                base.evaluate();  // 运行测试方法
                // 在base.evaluate()之后加上自定义的动作
                System.out.println("Class name: " + className + ", method name: " + methodName);
            }
        };
    }
}

public class ExampleTest {
    @Rule
    public TestRuleDemo demo = new TestRuleDemo();
    @Test
    public void testAdd() throws Exception {
        assertEquals(11, 5 + 6);
    }
}

内嵌测试

JUnit5提供嵌套单元测试功能,可更好展示测试类之间的业务逻辑关系,通常是一个业务对应一个测试类,有业务关系的类其实可以写在一起。这样有利于进行测试。而且内联的写法可以大大减少不必要的类,精简项目,防止类爆炸等一系列问题。

@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("Junit5单元测试")
public class MockTest {
    @Nested
    @DisplayName("内嵌订单测试")
    class OrderTestClas {
        @Test
        @DisplayName("取消订单")
        void cancelOrder() {
            int status = -1;
            System.out.println("取消订单成功,订单状态为:" + status);
        }
    }
}

JUnit Theories

使用JUnit Theories工具,测试被分成两个部分:一个是提供数据点集(比如待测试的数据)的方法,另一个是理论本身。这个理论看起来几乎就像一个测试,但是它有一个不同的注解(@Theory),并且它需要参数。类通过使用数据点集的任意一种可能的组合来执行所有理论。

使用assume使得你可以在对理论测试前首先检查一下前提条件。如果条件不是一个正确的给定参数集,那么此理论将会跳过此参数集。

除了简洁,由测试/理论模型实现的对测试数据进行的分离还有另外一点好处:你可能会开始考虑使你的测试数据独立于实际的东西来测试。

比如设计一个专门用来货币计算的计算器,首先需要给代码行为编写测试用例(这里以英镑 Pound 的乘法为例)
一个测试用例可能不够,需要再多一个:

@Test
public void multiplyPoundsByInteger () {
    assertEquals(10, new Pound(5).times(2).getAmount());
    assertEquals(15, new Pound(5).times(3).getAmount());
}

2个也不够,需要更多的测试用例。

// 利用变量来代替具体数据表达测试思想
public void multiplyAnyAmountByInteger(int amount, int multiplier) {
    assertEquals(amount * multiplier, new Pound(amount).times(multiplier).getAmount());
}

利用上面的方法,可将测试用例改写:

@Test
public void multiplyPoundsByInteger() {
    multiplyAnyAmountByInteger(5, 2);
    multiplyAnyAmountByInteger(5, 3);
}

以后若想增加测试用例,只要不停调用 multiplyAnyAmountByInteger 方法并赋予参数值即可。

方法 multiplyAnyAmountByInteger 就是理论的一个简单例子,理论就是一个带有参数的方法,其行为就是对任何参数都是正常的返回,不会抛出断言错误和其它异常。理论就是对一组数据进行概括性的陈述,如果没有对所有可能出现的情况都进行实验,是不能证明该理论是正确的,但是只要有一种错误情况出现,该理论就不成立。相反地,一个测试就是对一个单独数据的单独陈述,就像是一个科学理论的实验一样。

如何使用理论机制
在JUnit的理论机制中,每个测试方法不再是由注释 @Test 指定的无参测试函数,而是由注释 @Theory 指定的带参数的测试函数,这些参数来自一个数据集(data sets),数据集通过注释 @DataPoint 指定。

JUnit会自动将数据集中定义的数据类型和理论测试方法定义的参数类型进行比较,如果类型相同,会将数据集中的数据通过参数一一传入到测试方法中。数据集中的每一个数据都会被传入到每个相同类型的参数中。这时有人会问了,如果参数有多个,而且类型都和数据集中定义的数据相同,怎么办?答案是,JUnit会将这些数据集中的数据进行一一配对组合(所有的组合情况都会被考虑到),然后将这些数据组合统统通过参数,一一传入到理论的测试方法中,但是用户可以通过假设机制(assumption)在断言函数(assertion)执行这些参数之前,对这些通过参数传进来的数据集中的数据进行限制和过滤,达到有目的地部分地将自己想要的参数传给断言函数来测试。只有满足所有假设的数据才会执行接下来的测试用例,任何一个假设不满足的数据,都会自动跳过该理论测试函数(假设 assumption 不满足的数据会被忽略,不再执行接下来的断言测试),如果所有的假设都满足,测试用例断言函数不通过才代表着该理论测试不通过。

import static org.hamcrest.Matchers.*; //指定接下来要使用的Matcher匹配符  
import static org.junit.Assume.*; //指定需要使用假设assume*来辅助理论Theory  
import static org.junit.Assert.*; //指定需要使用断言assert*来判断测试是否通过  
  
import org.junit.experimental.theories.DataPoint;   //需要使用注释@DataPoint来指定数据集  
import org.junit.experimental.theories.Theories; //接下来@RunWith要指定Theories.class   
import org.junit.experimental.theories.Theory; //注释@Theory指定理论的测试函数  
import org.junit.runner.RunWith; //需要使用@RunWith指定接下来运行测试的类  
  
//注意:必须得使用@RunWith指定Theories.class  
@RunWith(Theories.class)  
public class TheoryTest {  
  
    //利用注释@DataPoint来指定一组数据集,这些数据集中的数据用来证明或反驳接下来定义的Theory理论,  
    //testNames1和testNames2这两个理论Theory测试函数的参数都是String,所以Junit4.4会将这5个  
    //@DataPoint定义的String进行两两组合,统统一一传入到testNames1和testNames2中,所以参数名year  
    //和name是不起任何作用的,"2007"同样有机会会传给参数name,"Works"也同样有机会传给参数year  
    @DataPoint public static String YEAR_2007 = "2007";  
    @DataPoint public static String YEAR_2008 = "2008";  
    @DataPoint public static String NAME1 = "developer";  
    @DataPoint public static String NAME2 = "Works";  
    @DataPoint public static String NAME3 = "developerWorks";  
  
    //注意:使用@Theory来指定测试函数,而不是@Test  
    @Theory   
    public void testNames1( String year, String name ) {  
        assumeThat( year, is("2007") ); //year必须是"2007",否则跳过该测试函数  
        System.out.println( year + "-" + name );  
        assertThat( year, is("2007") ); //这里的断言语句没有实际意义,这里举此例只是为了不中断测试  
    }  
  
    //注意:使用@Theory来指定测试函数,而不是@Test  
    @Theory  
    public void testNames2( String year, String name ) {  
        assumeThat(year, is("2007")); //year必须是"2007",否则跳过该测试函数  
        //name必须既不是"2007"也不是"2008",否则跳过该测试函数  
        assumeThat(name, allOf( not(is("2007")), not(is("2008"))));  
        System.out.println( year + "-" + name );  
        assertThat( year, is("2007") ); //这里的断言语句没有实际意义,这里举此例只是为了不中断测试  
    }
}

结果输出:
第一个Theory打印出:
2007-2007
2007-2008
2007-developer
2007-Works
2007-developerWorks
第二个Theory打印出:
2007-developer
2007-Works
2007-developerWorks

重复性测试

场景:对幂等性接口的测试。通过使用@RepeatedTest(n)指定需要重复的次数,可用于类和方法

参数化测试

在实际项目中,会遇到一些分支语句,一个测试用例已经不能覆盖全部分支语句。参数化测试主要包括五个步骤:

  1. 为准备使用参数化测试的测试类指定特殊的运行器org.junit.runners.Parameterized
  2. 为测试类声明几个变量,分别用于存放期望值和测试所用数据
  3. 为测试类声明一个带有参数的公共构造函数,并在其中为第二个环节中声明的几个变量赋值
  4. 为测试类声明一个使用注解org.junit.runners.Parameterized.Parameters修饰的,返回值为java.util.Collection的公共静态方法,并在此方法中初始化所有需要测试的参数对
  5. 编写测试方法,使用定义的变量作为参数进行测试

待测方法:

// 多分支语句
public boolean parameterization(int a){
	if ( a > 10 ) {
		return true;
	} else {
		return false;
	}
}
@RunWith(Parameterized.class) // 第一步
public class FirstDemoTestParameterization {
	// 要测试的类
    private FirstDemo firstDemo;
	// 第二步:为测试类声明几个变量,分别用于存放期望值和测试所用数据
	private int input1;
    private boolean expected;
	 
	@Before
	publicvoid setUp() throws Exception {
		firstDemo = new FirstDemo();
	}
 
    // 第三步:带有参数的公共构造函数,并在其中为声明的几个变量赋值
    public FirstDemoTestParameterization(int input1, boolean expected) {
       this.input1 = input1;  // 参数1
       this.expected = expected;  //期待的结果值
    }
	// 第四步:为测试类声明一个注解@Parameters,返回值为Collection的公共静态方法,并初始化所有需要测试的参数对
   @Parameters
   public static Collection prepareData() {
       Object[][] object = {{-1, true}, {13, true}}; // 测试数据
       return Arrays.asList(object); // 将数组转换成集合返回
    }
    
    @Test
    publicvoidtestParameterization() {
		// 第五步:编写测试方法,使用定义的变量作为参数进行测试。
		assertEquals(expected, firstDemo.Parameterization(input1));
    }
}

参数化测试:

  1. @RunWith(Parameterized.class):在每个需要参数化测试的类上面,都需要写上@RunWith(Parameterized.class)因为这样JUnit才会使用Parameterized运行器来运行测试,否则JUnit会选择默认的运行器,而默认运行器的不支持参数化测试
  2. @Parameters:在提供数据的static方法上加@Parameters,返回Collection

参数化测试可以按照多个参数分别运行多次单元测试这里有点类似于重复性测试,只不过每次运行传入的参数不用。需要使用到@ParameterizedTest,同时也需要@ValueSource提供一组数据,支持八种基本类型以及String和自定义对象类型。

@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
@DisplayName("参数化测试")
void paramTest(int a) {
    assertTrue(a > 0 && a < 4);
}

测试数据外部化

JUnit4及TestNG支持外部化测试数据,以便可以针对不同的数据集运行测试用例,而无需更改源代码。

MathChecker类有方法可以检查一个数字是否是奇数:

public class MathChecker {
    public Boolean isOdd(int n){
        if (n % 2 != 0) {
            return true;
        } else {
            returnfalse;
        }
    }
}

以下是MathChecker类的TestNG测试用例:

public class MathCheckerTest {
    private MathChecker checker;
    @BeforeMethod
    public void beforeMethod() {
    	checker = new MathChecker();
    }
    @Test
    @Parameters("num")
    public void isOdd(int num) { 
		Boolean result = checker.isOdd(num);
		Assert.assertEquals(result, new Boolean(true));
    }
}

为测试用例提供数据的2种方式:

  1. 配置文件testng.xml
  1. DataProvider注解

JUnit与TestNG类似,测试数据也可以外部化用于JUnit。以下是与上述相同MathChecker类的JUnit测试用例:

@RunWith(Parameterized.class)
public class MathCheckerTest {
    private int inputNumber;
    private Boolean expected;
    private MathChecker mathChecker;
    @Before
    public void setup() {
        mathChecker = new MathChecker();
    }
    // Inject via constructor
    public MathCheckerTest(int inputNumber, Boolean expected){
        this.inputNumber = inputNumber;
        this.expected = expected;
    }
    
    @Parameterized.Parameters
    public static Collection<Object[]> getTestData() {
        return Arrays.asList(new Object[][]{
                {1, true},
                {2, false},
                {3, true},
                {4, false},
                {5, true}
        });
    }
    @Test
    public void testisOdd() {
        System.out.println("Running test for:" + inputNumber);
        assertEquals(mathChecker.isOdd(inputNumber), expected);
    }
}

要对其执行测试的测试数据由getTestData()方法指定。此方法可以轻松地修改为从外部文件读取数据,而不是硬编码数据。

隔离测试

防止多类之间依赖的测试。比如B层对D层的依赖。测试B层时,不可能还要跑D层,这样的话就不是单元测试。不需要跑D层,但是又需要D层的返回值。隔离测试解决这个问题。用Mockito来进行隔离测试。隔离测试,即Mock,模拟的功能。当依赖其他类时,不需要真实调用,只需模拟出该类即可。

Public boolean IsExist(Student student) { 
	List studentList = null;
	Boolean isExist = true; 
	if (student.getName() != null) {
		studentList = studentDao.queryStudent(student.getName());
		if (studentList.isEmpty()) {
			isExist = false;
		} else {
			isExist = true;
		}
	}
	return isExist;
}

测试方法:

import static org.junit.Assert.*;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class StudentServiceTest {
	@Mock
	StudentDao studentDao;
	
	@InjectMocks //自动注入Mock类(StudentDao)到被测试类(StudentService),作为一个属性
	StudentService studentService;
	
	@Test
	public void testIsExist(){       
		//(1)实例化一个实体类expectStudent,并为其赋值
	   Student expectStudent = new Student();
	   expectStudent.setName("001");
	   //(2)实例化一个List集合,并将赋值后的expectStudent实体类放入集合中
	   List<Student> mockStudentList = newArrayList<Student>();
	   mockStudentList.add(expectStudent);
	   //(3)当调用模拟类的方法时,返回List集合
		when(studentDao.queryStudent(expectStudent.getName())).thenReturn(mockStudentList);
		//------------------(二)实际值:测试Service层方法,并且返回实际值-----------
		Student actualStudent1 = new Student();
		actualStudent1.setName("001");
		boolean res = studentService.IsExist(actualStudent1);
		//------------------(三)期望值与实际值比较:测试Service层方法,并且返回实际值
		assertTrue(studentService.IsExist(actualStudent1));
		Student actualStudent2 = new Student();
		actualStudent2.setName("002");
		boolean res2 = studentService.IsExist(actualStudent2);
		assertTrue(studentService.IsExist(actualStudent2));
	}
}

参考

JUnit Rule是什么
Junit 5新特性全集
JUnit Theories介绍

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

johnny233

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值