todo:此处直接使用单元测试,跨渡过大。应该先给出传统初始化CM层的方法,并按传统计方法对接学生新增后再分步介绍单元测试的方法。 ![](https://img.kancloud.cn/ec/6d/ec6d6bc7d3ed87a7975f7df3e62c212c_650x153.png) # 实体间关系 从广义上讲,实体间的关系可以分为:`一对一 1:1`、`一对多 1:n(多对一 n:1)`以及`多对多 m:n`三种。以我们当前的ER图为例:教师与班级的关系为`一对多`,即每个教师可以管理多个班级,同时每个班级只能被一个教师管理;同时班级与学生的关系也是`一对多`,即每个班级可以有多个学生,同时每个学生只能属于一个班级。在前期确立实体间的关系时,使用广义的定义就足够了。但在处理一些具体的校验问题时,就显得力不从心了。比如我们在当前系统中规定,只有存在学生那必须为其指定一个班级,而初始化班级的时候,则该班级中不见得必须有学生。这更符合现实情况,在招生还没有开始以前,我们允许管理员维护新的班级;在招生工作结束后,我们允许管理员向特定的班级中增加学生。而在录取的过程中不可能存在没有班级的学生,所以我们的系统也不允许此类事情的发生。这可以为我们减少人为的失误给系统带来的不确定性风险。假设我们不强制要求学生必须存在于班级之中,那么管理员录入时就可能忘记选择该学生的所在班级,最终的结果就是系统在任何班级中都无法找到该学生的信息,而如果系统未提供查询无班级学生功能的话,那么此学生数据就会成为一个永远也获取不到的数据。 而狭义的实体关系恰恰能够很好的描述此类问题。在狭义的定义中,`1`具体表述为`0..1`、`1`,`n`具体表示为`0..n`、`1..n`。以我们当前的项目为例:在初始化学生时必须为其指定班级,班级在初始化时可以没有任何学生,所以班级与学生的关系具体描述为:`1`:`0..n`,反应到ER图上如下: ![](https://img.kancloud.cn/da/05/da05d2ebb310696023238a9398d43a86_324x119.png) 在ER图上的中 ![](https://img.kancloud.cn/8e/02/8e02d23e5e5cbd8cab1b1859de96d29c_108x50.png) 代表1, ![](https://img.kancloud.cn/6d/93/6d9317d027d99b8c48ae77b899d33e20_83x27.png)这个小圈代表0, ![](https://img.kancloud.cn/c9/64/c964c016622756afbfa39d406fdf10ad_129x42.png)代表n; 所以以下ER图 ![](https://img.kancloud.cn/ec/6d/ec6d6bc7d3ed87a7975f7df3e62c212c_650x153.png) 则应具体描述为:`教师:班级` = `0..1 : 0..n`;`班级:学生` = `1: 0..n`。也就是说:可以存在没有教师的班级,但不能存在没有班级的学生。 ## @JoinColumn(nullable = false) 在spring data jpa中,我们使用@JoinColumn(nullable = false)来定义某个关联实体的字段不能为null。比如按`班级:学生` = `1: 0..n`的关系,我们应该如下初始化Student实体类。 entity/Student.java ``` package com.mengyunzhi.springBootStudy.entity; import javax.persistence.*; @Entity public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String sno; @ManyToOne @JoinColumn(nullable = false➋) ➊ private Klass klass; public Student() { } ...请自行补充setter/getter } ``` * ➊ 对关联实体字段做个性化设置 * ➋ 该字段必须有值,不能为null ## 单元测试 让我们使用单元的方法来测试一下使用@JoinColumn(nullable = false)注解后,当klass的值为null会发生什么错误。首先,我们建立更加方便操作Student的仓库接口。 repository/StudentRepository.java ``` package com.mengyunzhi.springBootStudy.repository; import com.mengyunzhi.springBootStudy.entity.Student; import org.springframework.data.repository.CrudRepository; /** * 学生 */ public interface StudentRepository extends CrudRepository<Student, Long> { } ``` 然后使用idea自动生成entity/Student.java对应的测试文件StudnetTest.java,并初始化如下: entity/StudentTest.java ``` package com.mengyunzhi.springBootStudy.entity; import com.mengyunzhi.springBootStudy.repository.StudentRepository; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; @SpringBootTest @RunWith(SpringRunner.class) public class StudentTest { @Autowired StudentRepository studentRepository; @Test public void save() { } } ``` ### 非null校验一 在save方法中添加语句,来尝试保存一个没有班级的学生实体。 ``` @Test public void save() { Student student = new Student(); this.studentRepository.save(student); } ``` 运行该测试,在控制台发生如下错误: ![](https://img.kancloud.cn/d6/4b/d64ba2c387a937b6778f5c1b01fc55df_1017x325.png) ``` 2019-11-19 14:08:40.461 WARN 15922 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1048, SQLState: 23000 2019-11-19 14:08:40.461 ERROR 15922 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : Column 'klass_id' cannot be null ``` 如日志如述,在保存时发生了1048错误,错误的详情为:'klass\_id' 列不能为null。而这正是我们想要的。 ### 断言异常 当异常发生而未被正确的处理时,程序将在异常处终止执行。比如我们刚刚的代码在执行了发生了异常,控制台显示此异常的类型为`org.springframework.dao.DataIntegrityViolationException:`,由于我们没有手动的处理这个异常,所以程序执行到此就终止了。也就是说即使我们继续在该发生异常的代码后编写正确的代码,也不会被执行。比如我们继续补充正确的代码: entity/StudentTest.java ``` /*班级*/ @Autowired KlassRepository klassRepository; /** * 保存测试 * 1. 直接保存空学生,断言null异常 * 2. 持久化一个班级 * 3. 设置学生的班级,再保存。成功 */ @Test public void save() { Student student = new Student(); this.studentRepository.save(student); /*此行及以下代码将不被执行*/ System.out.println("程序执行到此,打印控制台"); Klass klass = new Klass(); this.klassRepository.save(klass); student.setKlass(klass); this.studentRepository.save(student); } ``` 此时我们重复前面的测试,将得到与上一次相同的运行结果,代码执行到第二行的this.studentRepository.save(student);发生了异常,因而直接终止了执行。 #### try catch 处理异常最简单最有效的方法就是try catch,比如我们可以使用以下代码来使得程序正常执行下去。 entity/StudentTest.java ``` @Test public void save() { Student student = new Student(); try { ➊ this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); } System.out.println("程序执行到此,打印控制台"); ``` * ➊ 使用try catch来获取异常 运行测试,单元测试通过,同时控制台打印了如下信息: ``` 2019-11-19 14:25:10.164 WARN 29533 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1048, SQLState: 23000 2019-11-19 14:25:10.164 ERROR 29533 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : Column 'klass_id' cannot be null 发生了异常 程序执行到此,打印控制台 ``` 虽然单元测试通过了,也于控制台中打印了应该打印的错误信息,但这会有一定的问题。比如我们来到Student实体类,去除klass字段上的@JoinColumn注解,然后再来运行该单元测试。尽管控制台没有打印'发生了异常',但单元测试同样被通过了。这违背了单元测试的初衷:在后续开发中,使用单元测试来保证该功能的正常运行。而我们希望的单元测试来保障:学生实体中的klass属性不能为null,如果为null那么单元测试就应该来报错。也就是说我们要在测试代码中保障该异常必然发生了,同时还不能够由于该异常的发生而影响后续的功能测试代码。 #### 小技巧 为此,我们增加一个是否发生异常的状态字段 ``` @Test public void save() { Student student = new Student(); boolean called = false; ① try { this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); called = true; ② } Assertions.assertThat(called).isTrue(); ③ ``` * ③ 如果没有发生异常,则called的值仍然为false,则此条断言没法通过 此时,若去除Student实体中klass字段上的@JoinColumn注解,再运行单元测试则会发生以下异常: ``` org.junit.ComparisonFailure: Expected :true ➊ Actual :false ➋ <Click to see difference> at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method) at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62) at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45) at com.mengyunzhi.springBootStudy.entity.StudentTest.save(StudentTest.java:41) ``` * ➊ 期望called的值为true * ➋ 但却接收到了false * 说明应该发生异常的点没有发生异常,单元测试不通过。 ### 非null校验2 按前面原型的设置,学号必须是6位长度的字符串,唯一且不能为空(null)。刚刚学习了使用@JoinColumn(nullable = false)进行字段的非null校验,那是否也可以将该注解直接添加到sno字段上呢?共同试试看。 ``` @JoinColumn(nullable = false) ★ private String sno; ``` 我们运行历史的单元测试,期望该测试能够发生异常来提醒我们: sno字段不能为null。但事与愿违: ![](https://img.kancloud.cn/4c/18/4c189fb4155e220f01e2c52d98efe4cf_602x122.png) 单元测试并没有发现sno为null的错误,这是由于: * @JoinColumn 注解用于关联实体的字段上,一般和@ManyToOne、@OneToOne配合使用。 * 一般的非关联实体的设置需要使用@Column注解。 ``` @Column(nullable = false) private String sno; ``` 此时我们再次执行单元测试,将得到如下错误: ``` 发生了异常 程序执行到此,打印控制台 2019-11-19 15:00:06.762 WARN 58189 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1048, SQLState: 23000 2019-11-19 15:00:06.762 ERROR 58189 --- [ main] o.h.engine.jdbc.spi.SqlExceptionHelper : Column 'sno' cannot be null org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement ``` 观察控制台我们发现,单元测试执行第一次save时,发生了异常并被我们正确的获取到了,但执行第二次的save的时候由于sno为null,所以再次发生了DataIntegrityViolationException类型的异常。 #### 断言异常 根据刚刚的经验,我们再次加入try catch来进行断言。 ``` public void save() { Student student = new Student(); boolean called = false; try { this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); called = true; } Assertions.assertThat(called).isTrue(); System.out.println("程序执行到此,打印控制台"); Klass klass = new Klass(); this.klassRepository.save(klass); called = false; try { student.setKlass(klass); this.studentRepository.save(student); }catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); called = true; } Assertions.assertThat(called).isTrue(); student.setSno("032282"); this.studentRepository.save(student); } ``` 测试通过。 ## 深入思考 虽然我们刚刚通过了单元测试,但单元测试的目的要是保证我们的代码在以后的很长的一段日子了都会如期运行。而上述代码中,我们再次删除Student类中klass字段上的@JoinColumn(nullable = false)注解,单元测试同样会被通过。 这是由于klass为null与sno为null的异常均为DataIntegrityViolationException类型,所以只通过异常的类型是不能够确认到是klass为null还是sno为null所导致的。 ![](https://img.kancloud.cn/d2/7b/d27be8121b3a67c762af0c7cb5d9655e_345x234.png) 如上图所示:第一次第二次全部是由于sno为null触发的,而我们却天真的认为第一次必然是由klass为null引发的。此时如果想进一步的区分DataIntegrityViolationException是由klass引起的还是由sno引起的,则需要对其异常的信息进行断言(判断)。 在发生异常时,我们会在控制台中获取到大面积的红色的字段的结果: ![](https://img.kancloud.cn/35/36/35367b293f8d3618a1a908fbcb7e1fc1_2742x426.png) 该结果是在向我们展示:此异常一步步的是由哪个方法抛出的(这个我们当前并不关心),以及在某个异常中打印了什么消息。在JPA进行保存操作发生DataIntegrityViolationException异常时,我们可以由`Caused by: java.sql.SQLIntegrityConstraintViolationException: Column 'klass_id' cannot be null`此句来推断出异常的具体消息。而我们如果想区域两个null异常,则需要 ①获取报错的全文 ②在全文中搜索关键字,当特定的关键字出现时,我们则认为发生了我们预期内的异常,方法如下: ``` try { this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); StringWriter stringWriter = new StringWriter(); ➊ e.printStackTrace(new PrintWriter(stringWriter)); ➋ Assertions.assertThat(stringWriter.toString()➌) .contains("Column 'klass_id' cannot be null");➍ called = true; } Assertions.assertThat(called).isTrue(); ``` * ➊➋ 固有写法,先照抄吧。StringWriter可以理解为我们现实生活中的**记事本**,今天写点放这,明天还可以今天的往后写。 * ➌ 获取当前**记事本**的内容 * ➍ 断言该内容中包括特定的定符串 ### 补全测试 ``` /** * 保存测试 * 1. 直接保存空学生,断言klass null异常 * 2. 持久化一个班级 * 3. 设置学生的班级,再保存,断言sno null异常 * 4. 设置学号 * 5. 保存成功 */ @Test public void save() { Student student = new Student(); boolean called = false; try { this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); StringWriter stringWriter = new StringWriter(); e.printStackTrace(new PrintWriter(stringWriter)); Assertions.assertThat(stringWriter.toString()) .contains("Column 'klass_id' cannot be null"); called = true; } Assertions.assertThat(called).isTrue(); System.out.println("程序执行到此,打印控制台"); Klass klass = new Klass(); this.klassRepository.save(klass); called = false; try { student.setKlass(klass); this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { System.out.println("发生了异常"); StringWriter stringWriter = new StringWriter(); e.printStackTrace(new PrintWriter(stringWriter)); Assertions.assertThat(stringWriter.toString()) .contains("Column 'sno' cannot be null"); called = true; } Assertions.assertThat(called).isTrue(); student.setSno("032282"); this.studentRepository.save(student); } ``` 至此,实体的非null校验完成。 ## 殊途同归 刚刚我们测试的步骤是: 先测试异常,最后再进行正常的测试。如果我们先测试正常的数据,然后再测试异常呢?下面我们使用**排除法**来进行NULL测试。 我们将一个单元测试用例拆分为多个用例,在每个用例前先生成一个可以正常保存的学生实体,然后分别在各个用例中来测试`正常保存`,`klass null异常`和`sno null`异常。 在java的单元测试中,我们使用@Before来标记该方法在每个测试用例执行前执行1次。 entity/StudentTest.java ``` private Klass klass; ① private Student student; ① ... /** * 在每个测试用例前执行一次 * 功能:初始化一个正常的学生 */ @Before ➊ public void before() { this.student = new Student(); if (this.klass == null) { ➋ this.klass = new Klass(); this.klassRepository.save(this.klass); } this.student.setName("测试名称"); this.student.setSno("032282"); this.student.setKlass(this.klass); } ``` * ➊ 用于单元测试,表示在每个测试前均执行1次方法 * ➋ 保证klass只被实例化1次 * ① 私有属性,作用域为本对象。这使得可以在多个方法中操作同一个对象,也就间接的实现了方法间的传值 。 ### 测试正常保存 ``` @Test public void saveTest() { this.studentRepository.save(this.student); } ``` 保存过程中未发生异常,保存操作通过。 ### 测试klass为null ``` @Test public void klassNullTest() { this.student.setKlass(null); boolean called = false; try { this.studentRepository.save(student); } catch (DataIntegrityViolationException e) { called = true; } Assertions.assertThat(called).isTrue(); } ``` 由于前面的saveTest方法保障了this.student正常保存是不会发生异常的。而在此测试中我们仅仅将其klass设置为null,发生异常则足矣说明该异常项是由klass为null而导致的。 如果在某个测试方法中,我们的目标就是为了测试某个异常,上述代码也可以简写为: ``` @Test(expected = DataIntegrityViolationException.class➊) public void klassNullTest() { this.student.setKlass(null); this.studentRepository.save(student); } ``` * ➊ 本测试期望得到一个DataIntegrityViolationException异常,如果该异常未发生则单元测试失败 ### 测试sno为null ``` @Test(expected = DataIntegrityViolationException.class) public void snoNullTest() { this.student.setSno(null); this.studentRepository.save(student); } ``` 此方法同测试klass为null ### 总结 我们将一个复杂的测试用例拆分为3个小的测试用例,在每个测试用例每别测试了1个小的功能点。方法的拆分降低了我们每个方法在编写时的思索量,同时代码也变得更清晰,当在以后的迭代开发中发现错误时也更容易的快速来定位到具体的错误。而如何进行拆分则更多的是一项技能,一项随着自己看的多、做的多、模仿的多而自然增长的编程技能。 **请自行完成name字段的null校验及测试方法后继续学习** # 参考文档 | 名称 | 链接 | 预计学习时长(分) | | --- | --- | --- | | 源码地址 | [https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.6](https://github.com/mengyunzhi/spring-boot-and-angular-guild/releases/tag/step4.5.6) | \- | | @Column | [https://docs.oracle.com/javaee/7/api/javax/persistence/Column.html](https://docs.oracle.com/javaee/7/api/javax/persistence/Column.html) | 5 | | @JoinColumn | [https://docs.oracle.com/javaee/7/api/javax/persistence/JoinColumn.html](https://docs.oracle.com/javaee/7/api/javax/persistence/JoinColumn.html) | 5 |