Java 枚举最佳实践:带映射缓存 + 可复用 fromXxx 方法 + JUnit5 单元测试
日常开发里,枚举是用来固化状态、类型、业务字典最常用的手段。很多同学只写简单枚举常量,但是根据 code / 描述反向解析枚举的时候写法五花八门:每次循环遍历、没有判空、异常信息不统一、Map 没有做不可变保护,很容易埋坑。
今天分享两套工程化枚举模板,一套多字段版本、一套精简版本,配套完整 JUnit5 单元测试,直接复制就能在项目里使用。
一、枚举设计思路
先说说这套枚举的设计要点,也是项目里推荐的规范:
- 使用
static静态代码块一次性预加载映射关系,避免每次解析都循环 values(),性能更好; - 内部缓存 Map 使用
Collections.unmodifiableMap包装,防止外部篡改映射表; - 提供
fromCode / fromDescription / fromField / fromName等反向解析方法; - 入参做空值、空白值校验,区分参数非法、找不到枚举两种异常;
- 使用 lombok
@Getter,只对外暴露 getter,字段全部private final,不可变; - 配套完整单元测试:覆盖正常场景、null、空串、空白串、无效值、ordinal、name、valueOf。
补充:
Enum.valueOf()是 JDK 原生方法,只能通过枚举字面量名称解析,无法自定义异常提示,所以我们封装fromName更适合业务使用。
二、模板1:多字段枚举(code + description + field)
适合字典有编码、中文说明、数据库字段标识三类属性的场景,例如检查频次、巡检类型。
import lombok.Getter;
import java.util.HashMap;
import java.util.Map;
/**
* @author admin1
* 检查频次枚举
*/
@Getter
public enum CheckFrequencyStatus {
ENTERPRISE_SPECIAL_INSPECTION(1, "测试数据001","csdata001"),
QUARTERLY_ENTERPRISE(2, "测试数据002","csdata002"),
DAILY_IN_CLASS_INSPECTION(3, "测试数据003","csdata003"),
DAILY_SHIFT_HANDOVER(4, "测试数据004","csdata004"),
PROJECT_WEEKLY_INSPECTION(5, "测试数据005","csdata005");
private final int code;
private final String description;
private final String field;
CheckFrequencyStatus(int code, String description, String field) {
this.code = code;
this.description = description;
this.field = field;
}
private static final Map<Integer, CheckFrequencyStatus> CODE_MAP = new HashMap<>();
private static final Map<String, CheckFrequencyStatus> DESC_MAP = new HashMap<>();
private static final Map<String, CheckFrequencyStatus> FIELD_MAP = new HashMap<>();
private static final Map<String, CheckFrequencyStatus> NAME_MAP = new HashMap<>();
static {
for (CheckFrequencyStatus status : values()) {
CODE_MAP.put(status.code, status);
DESC_MAP.put(status.description, status);
FIELD_MAP.put(status.field, status);
NAME_MAP.put(status.name(), status);
}
}
public static CheckFrequencyStatus fromCode(int code) {
CheckFrequencyStatus status = CODE_MAP.get(code);
if (status == null) {
throw new IllegalArgumentException("无效的状态码: " + code);
}
return status;
}
public static CheckFrequencyStatus fromDescription(String description) {
CheckFrequencyStatus status = DESC_MAP.get(description);
if (status == null) {
throw new IllegalArgumentException("无效的描述: " + description);
}
return status;
}
public static CheckFrequencyStatus fromField(String field) {
CheckFrequencyStatus status = FIELD_MAP.get(field);
if (status == null) {
throw new IllegalArgumentException("无效的状态值: " + field);
}
return status;
}
/**
* 通过枚举字面量名称获取实例(替代原生 valueOf,自定义异常)
*/
public static CheckFrequencyStatus fromName(String name) {
CheckFrequencyStatus status = NAME_MAP.get(name);
if (status == null) {
throw new IllegalArgumentException("无效的枚举名称: " + name);
}
return status;
}
public static void main(String[] args) {
CheckFrequencyStatus status = CheckFrequencyStatus.fromCode(1);
System.out.println(status);
System.out.println(status.field);
System.out.println(status.code);
System.out.println(status.description);
System.out.println(status.name());
System.out.println(status.getCode()+"---"+status.getDescription()+"---"+status.getField());
String es = "ENTERPRISE_SPECIAL_INSPECTION";
CheckFrequencyStatus status1 = CheckFrequencyStatus.valueOf(es);
System.out.println(status1);
System.out.println(status1.name());
System.out.println(status1.getCode()+"---"+status1.getDescription()+"---"+status1.getField());
CheckFrequencyStatus status2 = CheckFrequencyStatus.fromName(es);
System.out.println(status2);
System.out.println(status2.name());
System.out.println(status2.getCode()+"---"+status2.getDescription()+"---"+status2.getField());
CheckFrequencyStatus[] values = CheckFrequencyStatus.values();
System.out.println(java.util.Arrays.toString(values));
}
}
三、模板2:精简版枚举(code + description,推荐业务大量使用)
这里做了优化:静态 Map 使用不可变集合,防止代码里不小心修改缓存映射表,异常区分更清晰:
IllegalArgumentException:参数为空、空白,入参本身非法NoSuchElementException:参数不为空,但是找不到对应的枚举
package com.example.demo.enumTest;
import lombok.Getter;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
/**
* 级别枚举
*/
@Getter
public enum JBStatus {
ZYJ("ZYJ", "作业级"),
XMJ("XMJ", "项目级"),
QYJ("QYJ", "企业级");
private final String code;
private final String description;
JBStatus(String code, String description) {
this.code = code;
this.description = description;
}
private static final Map<String, JBStatus> CODE_MAP;
private static final Map<String, JBStatus> DESC_MAP;
static {
Map<String, JBStatus> codeMap = new HashMap<>();
Map<String, JBStatus> descMap = new HashMap<>();
for (JBStatus status : values()) {
codeMap.put(status.code, status);
descMap.put(status.description, status);
}
// 包装为不可变Map,防止外部修改缓存
CODE_MAP = Collections.unmodifiableMap(codeMap);
DESC_MAP = Collections.unmodifiableMap(descMap);
}
/**
* 通过code获取枚举值
*/
public static JBStatus fromCode(String code) {
if (code == null || code.trim().isEmpty()) {
throw new IllegalArgumentException("状态码不能为空");
}
JBStatus JBStatus = CODE_MAP.get(code);
if (JBStatus == null) {
throw new NoSuchElementException("无效的状态码: " + code);
}
return JBStatus;
}
/**
* 通过description获取枚举值
*/
public static JBStatus fromDescription(String description) {
if (description == null || description.trim().isEmpty()) {
throw new IllegalArgumentException("状态值不能为空");
}
JBStatus JBStatus = DESC_MAP.get(description);
if (JBStatus == null) {
throw new NoSuchElementException("无效的状态值: " + description);
}
return JBStatus;
}
}
四、配套 JUnit5 单元测试
很多开发枚举写完直接裸奔,没有测试,新增/删除枚举常量后很容易改错映射关系。
这套测试覆盖:常量属性校验、values、fromCode、fromDescription、空值、空白串、无效值、ordinal、name、原生valueOf。
package com.example.demo.enumTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import java.util.NoSuchElementException;
import static org.junit.jupiter.api.Assertions.*;
/**
* JBStatus 枚举类的单元测试
* 测试枚举常量、静态方法和边界条件
*/
public class JBStatusTest {
@Test
@DisplayName("测试枚举常量 ZYJ 的值")
public void testEnumConstantZYJ() {
assertEquals("ZYJ", JBStatus.ZYJ.getCode());
assertEquals("作业级", JBStatus.ZYJ.getDescription());
}
@Test
@DisplayName("测试枚举常量 XMJ 的值")
public void testEnumConstantXMJ() {
assertEquals("XMJ", JBStatus.XMJ.getCode());
assertEquals("项目级", JBStatus.XMJ.getDescription());
}
@Test
@DisplayName("测试枚举常量 QYJ 的值")
public void testEnumConstantQYJ() {
assertEquals("QYJ", JBStatus.QYJ.getCode());
assertEquals("企业级", JBStatus.QYJ.getDescription());
}
@Test
@DisplayName("测试 values() 方法返回所有枚举常量")
public void testValuesMethod() {
JBStatus[] values = JBStatus.values();
assertEquals(3, values.length);
assertTrue(containsEnum(values, JBStatus.ZYJ));
assertTrue(containsEnum(values, JBStatus.XMJ));
assertTrue(containsEnum(values, JBStatus.QYJ));
}
@Test
@DisplayName("测试 fromCode 方法 - 有效的 code 值")
public void testFromCodeWithValidCodes() {
assertEquals(JBStatus.ZYJ, JBStatus.fromCode("ZYJ"));
assertEquals(JBStatus.XMJ, JBStatus.fromCode("XMJ"));
assertEquals(JBStatus.QYJ, JBStatus.fromCode("QYJ"));
}
@Test
@DisplayName("测试 fromCode 方法 - null 值应抛出 IllegalArgumentException")
public void testFromCodeWithNull() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> JBStatus.fromCode(null)
);
assertEquals("状态码不能为空", exception.getMessage());
}
@Test
@DisplayName("测试 fromCode 方法 - 空字符串应抛出 IllegalArgumentException")
public void testFromCodeWithEmptyString() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> JBStatus.fromCode("")
);
assertEquals("状态码不能为空", exception.getMessage());
}
@Test
@DisplayName("测试 fromCode 方法 - 空白字符串应抛出 IllegalArgumentException")
public void testFromCodeWithBlankString() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> JBStatus.fromCode(" ")
);
assertEquals("状态码不能为空", exception.getMessage());
}
@Test
@DisplayName("测试 fromCode 方法 - 无效的 code 值应抛出 NoSuchElementException")
public void testFromCodeWithInvalidCode() {
NoSuchElementException exception = assertThrows(
NoSuchElementException.class,
() -> JBStatus.fromCode("INVALID")
);
assertEquals("无效的状态码: INVALID", exception.getMessage());
}
@Test
@DisplayName("测试 fromDescription 方法 - 有效的 description 值")
public void testFromDescriptionWithValidDescriptions() {
assertEquals(JBStatus.ZYJ, JBStatus.fromDescription("作业级"));
assertEquals(JBStatus.XMJ, JBStatus.fromDescription("项目级"));
assertEquals(JBStatus.QYJ, JBStatus.fromDescription("企业级"));
}
@Test
@DisplayName("测试 fromDescription 方法 - null 值应抛出 IllegalArgumentException")
public void testFromDescriptionWithNull() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> JBStatus.fromDescription(null)
);
assertEquals("状态值不能为空", exception.getMessage());
}
@Test
@DisplayName("测试 fromDescription 方法 - 空字符串应抛出 IllegalArgumentException")
public void testFromDescriptionWithEmptyString() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> JBStatus.fromDescription("")
);
assertEquals("状态值不能为空", exception.getMessage());
}
@Test
@DisplayName("测试 fromDescription 方法 - 无效的 description 值应抛出 NoSuchElementException")
public void testFromDescriptionWithInvalidDescription() {
NoSuchElementException exception = assertThrows(
NoSuchElementException.class,
() -> JBStatus.fromDescription("无效描述")
);
assertEquals("无效的状态值: 无效描述", exception.getMessage());
}
@Test
@DisplayName("测试 fromCode 和 fromDescription 方法间接验证映射正确性")
public void testMappingsCorrectness() {
assertEquals(JBStatus.ZYJ, JBStatus.fromCode("ZYJ"));
assertEquals(JBStatus.XMJ, JBStatus.fromCode("XMJ"));
assertEquals(JBStatus.QYJ, JBStatus.fromCode("QYJ"));
assertEquals(JBStatus.ZYJ, JBStatus.fromDescription("作业级"));
assertEquals(JBStatus.XMJ, JBStatus.fromDescription("项目级"));
assertEquals(JBStatus.QYJ, JBStatus.fromDescription("企业级"));
}
@Test
@DisplayName("测试枚举常量的 ordinal 值")
public void testEnumOrdinal() {
assertEquals(0, JBStatus.ZYJ.ordinal());
assertEquals(1, JBStatus.XMJ.ordinal());
assertEquals(2, JBStatus.QYJ.ordinal());
}
@Test
@DisplayName("测试枚举常量的 name 值")
public void testEnumName() {
assertEquals("ZYJ", JBStatus.ZYJ.name());
assertEquals("XMJ", JBStatus.XMJ.name());
assertEquals("QYJ", JBStatus.QYJ.name());
}
@Test
@DisplayName("测试 valueOf 方法")
public void testValueOfMethod() {
assertEquals(JBStatus.ZYJ, JBStatus.valueOf("ZYJ"));
assertEquals(JBStatus.XMJ, JBStatus.valueOf("XMJ"));
assertEquals(JBStatus.QYJ, JBStatus.valueOf("QYJ"));
}
@Test
@DisplayName("测试 valueOf 方法 - 无效名称应抛出 IllegalArgumentException")
public void testValueOfWithInvalidName() {
assertThrows(IllegalArgumentException.class,
() -> JBStatus.valueOf("INVALID"));
}
/**
* 辅助方法: 检查枚举数组是否包含指定的枚举常量
*/
private boolean containsEnum(JBStatus[] array, JBStatus target) {
for (JBStatus item : array) {
if (item == target) {
return true;
}
}
return false;
}
}
五、常见问题对比 & 踩坑点
1. 为什么不用每次调用都循环 values()?
// 不推荐写法,每次调用都循环
public static JBStatus fromCode(String code){
for(JBStatus e : values()){
if(e.code.equals(code)) return e;
}
throw new NoSuchElementException();
}
- 少量枚举感知不明显;高频解析(接口、循环里)会有额外开销;
- 静态块只加载一次,O(1) 查询,性能稳定。
2. 什么时候用 Collections.unmodifiableMap?
团队多人协作、基础枚举、公共字典枚举,强烈建议加上,防止有人写代码直接 CODE_MAP.put() 篡改缓存。
3. fromName 和原生 Enum.valueOf() 的区别
Enum.valueOf():JDK原生,异常信息固定,不能自定义提示;找不到直接抛IllegalArgumentException;fromName:自己封装,异常文案可控,适合业务返回给前端、日志打印。
4. 异常怎么选?
- 参数 null / 空 / 空白:
IllegalArgumentException(参数非法) - 参数格式正常,但是字典不存在:
NoSuchElementException(找不到资源)>
也可以统一自定义业务异常,和全局异常处理器配合,直接返回前端友好提示。
六、扩展延伸(可选)
- 配合 Jackson 序列化:自定义
@JsonValue输出 code; - MyBatis 枚举处理器:直接数据库存 code,自动映射枚举;
- 增加
getAllList()静态方法,返回枚举下拉列表给前端; - 自定义统一业务异常,替换 JDK 原生异常,统一响应码。
七、小结
这套枚举模板是项目里沉淀下来的通用模板:
✅ 缓存映射,O(1) 查询
✅ 入参校验,边界防护
✅ 不可变Map保护缓存
✅ 异常区分清晰
✅ 全量JUnit5单元测试,改枚举不怕改错
需要新增字典枚举,直接复制模板改常量即可,测试类也可以按同样套路生成。
- 点赞
- 收藏
- 关注作者
评论(0)