一、核心概念
FreeMarker 模板引擎(
freemarker.template.Configuration
)
是 FreeMarker 的核心配置类,用于加载模板、设置全局参数(如编码、缓存策略)、管理共享变量等。模板(
Template
)
通过Configuration.getTemplate("template.ftl")
加载的.ftl
文件,是执行渲染的主体。数据模型(
Map<String, Object>
)
传递给模板的数据上下文,通常是一个Map
,包含模板中使用的变量、列表、对象等。渲染输出(
Writer
)
模板渲染的结果输出到Writer
(如StringWriter
),可转换为字符串进行断言。单元测试框架(JUnit / TestNG)
使用 JUnit 5 或 TestNG 编写测试用例,结合断言库(如 AssertJ、Hamcrest)验证输出。测试范围
- 条件判断(
#if
,#elseif
,#else
) - 循环结构(
#list
,#items
) - 变量赋值与表达式计算
- 内建函数使用(
?upper_case
,?size
,?html
等) - 宏(
#macro
)调用与参数传递 - 包含(
#include
,#import
)逻辑
- 条件判断(
二、操作步骤(非常详细)
以下以 Maven + JUnit 5 + FreeMarker 2.3.32 + AssertJ 为例,详细演示如何对 FreeMarker 模板进行单元测试。
步骤 1:添加依赖(Maven)
在 pom.xml
中添加:
<dependencies>
<!-- FreeMarker 核心 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
<scope>compile</scope>
</dependency>
<!-- JUnit 5 测试框架 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<!-- AssertJ 断言库(推荐) -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies>
步骤 2:准备测试模板文件
在 src/test/resources/templates/
目录下创建模板文件 user-profile.ftl
:
<#-- user-profile.ftl -->
<html>
<head><title>User Profile</title></head>
<body>
<h1>Welcome, ${user.name?html}!</h1>
<#if user.age??>
<p>Age: ${user.age}</p>
<#else>
<p>Age: Not specified</p>
</#if>
<#if user.hobbies?has_content>
<ul>
<#list user.hobbies as hobby>
<li>${hobby?upper_case}</li>
</#list>
</ul>
<#else>
<p>No hobbies listed.</p>
</#if>
<#-- 使用宏 -->
<#macro greet name>
Hello, ${name}!
</#macro>
<#-- 调用宏 -->
<div><@greet name="Alice" /></div>
</body>
</html>
步骤 3:编写单元测试类
创建测试类 FreeMarkerTemplateTest.java
,路径:src/test/java/com/example/FreeMarkerTemplateTest.java
package com.example;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.Version;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class FreeMarkerTemplateTest {
private Configuration configuration;
@BeforeEach
void setUp() {
// 1. 创建 FreeMarker Configuration
configuration = new Configuration(new Version(2, 3, 32)); // 指定版本
// 2. 设置模板加载路径(指向 src/test/resources/templates)
// 使用 ClassTemplateLoader 从类路径加载
configuration.setClassForTemplateLoading(FreeMarkerTemplateTest.class, "/templates");
// 3. 设置默认编码
configuration.setDefaultEncoding("UTF-8");
// 4. 设置异常处理(开发测试时建议设为调试模式)
configuration.setTemplateExceptionHandler(
freemarker.template.TemplateExceptionHandler.DEBUG_HANDLER
);
// 5. 关闭缓存(测试环境,确保每次加载最新模板)
configuration.setTemplateLookupStrategy(
new freemarker.cache.StrongCacheStorage()
);
// 或者:configuration.setIncompatibleImprovements(new Version(2, 3, 20));
}
/**
* 测试:正常用户数据渲染
*/
@Test
void shouldRenderUserProfileWithHobbies() throws IOException, TemplateException {
// 1. 加载模板
Template template = configuration.getTemplate("user-profile.ftl");
// 2. 构造数据模型
Map<String, Object> dataModel = new HashMap<>();
Map<String, Object> user = new HashMap<>();
user.put("name", "张三 & 张");
user.put("age", 25);
user.put("hobbies", Arrays.asList("reading", "swimming"));
dataModel.put("user", user);
// 3. 渲染模板
Writer out = new StringWriter();
template.process(dataModel, out);
String result = out.toString();
// 4. 断言输出内容
assertThat(result).contains("<h1>Welcome, 张三 & 张!</h1>");
assertThat(result).contains("<p>Age: 25</p>");
assertThat(result).contains("<li>READING</li>");
assertThat(result).contains("<li>SWIMMING</li>");
assertThat(result).contains("Hello, Alice!");
}
/**
* 测试:用户无年龄字段
*/
@Test
void shouldHandleUserWithoutAge() throws IOException, TemplateException {
Template template = configuration.getTemplate("user-profile.ftl");
Map<String, Object> dataModel = new HashMap<>();
Map<String, Object> user = new HashMap<>();
user.put("name", "李四");
// 不设置 age 字段
user.put("hobbies", Arrays.asList("coding"));
dataModel.put("user", user);
Writer out = new StringWriter();
template.process(dataModel, out);
String result = out.toString();
assertThat(result).contains("Age: Not specified");
assertThat(result).doesNotContain("Age: null");
}
/**
* 测试:用户无爱好
*/
@Test
void shouldHandleUserWithoutHobbies() throws IOException, TemplateException {
Template template = configuration.getTemplate("user-profile.ftl");
Map<String, Object> dataModel = new HashMap<>();
Map<String, Object> user = new HashMap<>();
user.put("name", "王五");
user.put("age", 30);
user.put("hobbies", null); // 或空集合
dataModel.put("user", user);
Writer out = new StringWriter();
template.process(dataModel, out);
String result = out.toString();
assertThat(result).contains("No hobbies listed.");
assertThat(result).doesNotContain("<ul>");
}
/**
* 测试:模板语法错误应抛出异常
*/
@Test
void shouldThrowExceptionOnInvalidTemplate() {
// 创建一个语法错误的模板内容(测试异常)
String invalidFtl = "<#if true><#else>";
// 注意:实际项目中模板是文件,这里演示可使用 StringTemplateLoader
// 本例不展开,但可通过自定义 TemplateLoader 测试
// 此处仅说明:异常处理应在配置中设置
}
}
步骤 4:运行测试
使用 IDE 或命令行运行测试:
mvn test
预期所有测试通过。
三、常见错误
错误现象 | 原因 | 解决方案 |
---|---|---|
TemplateNotFoundException |
模板路径错误或未放在 resources 下 |
检查 setClassForTemplateLoading 路径是否正确 |
TemplateException 语法错误 |
模板中存在 #if 不匹配、变量名错误等 |
启用 DEBUG_HANDLER 查看详细错误信息,检查模板 |
渲染输出为空或不完整 | Writer 未正确 flush 或 close |
使用 StringWriter 无需手动 flush,调用 toString() 即可 |
断言失败(如 HTML 转义未生效) | 忘记使用 ?html 或测试数据未转义 |
在模板中使用 ?html ,断言时检查转义后内容(如 & ) |
缓存导致修改模板不生效 | 测试环境未关闭缓存 | 设置 setTemplateLookupStrategy 或使用 StringTemplateLoader |
NoClassDefFoundError |
缺少 FreeMarker 依赖 | 检查 pom.xml 是否包含 freemarker 依赖 |
四、注意事项
- 模板路径必须在类路径下:使用
setClassForTemplateLoading
时,路径是相对于类的包路径或资源目录。 - 编码一致性:确保模板文件保存为 UTF-8,且
Configuration
中设置setDefaultEncoding("UTF-8")
。 - 异常处理器设置:测试时建议使用
DEBUG_HANDLER
,生产环境可用RETHROW_HANDLER
。 - 避免缓存干扰:单元测试中建议关闭缓存或使用
StringTemplateLoader
临时加载模板字符串。 - HTML 转义安全:测试时验证
?html
是否生效,防止 XSS。 - 宏与包含测试:若模板使用
#include
,需确保被包含文件也在类路径下。
五、使用技巧
使用
StringTemplateLoader
测试片段
适合测试宏或小片段,无需创建文件:StringTemplateLoader loader = new StringTemplateLoader(); loader.putTemplate("test.ftl", "<#macro m>x</#macro><@m/>"); configuration.setTemplateLoader(loader);
断言 HTML 结构(高级)
使用jsoup
解析 HTML 并断言结构:Document doc = Jsoup.parse(result); assertThat(doc.select("h1").text()).isEqualTo("Welcome, 张三 & 张!");
参数化测试(JUnit 5)
对同一模板测试多组数据:@ParameterizedTest @MethodSource("provideUserData") void shouldRenderWithDifferentUsers(UserData data) { ... }
测试内建函数
显式测试?upper_case
,?date
,?number
等是否符合预期。
六、最佳实践与性能优化
实践 | 说明 |
---|---|
✅ 测试覆盖率高 | 覆盖 if , list , macro , include , ?exists , ?? , ! 等语法 |
✅ 隔离测试环境 | 使用独立的测试模板目录,避免影响生产模板 |
✅ 使用 AssertJ 提升可读性 | assertThat(...).contains(...) 比 assertTrue 更清晰 |
✅ 命名规范 | 测试方法名清晰表达测试场景,如 shouldRenderWhenUserHasHobbies |
✅ 模拟边界情况 | 测试 null 、空集合、特殊字符、长文本等 |
✅ 集成 CI/CD | 将模板测试纳入持续集成流程 |
⚠️ 避免过度测试 | 不测试 FreeMarker 引擎本身功能(如 ?html 是否转义),而是测试业务逻辑是否正确调用 |
⚡ 性能优化(测试层面) | |
- 重用 Configuration 实例(@BeforeEach 初始化一次) |
减少重复创建开销 |
- 避免在循环中频繁 getTemplate |
可缓存 Template 对象(测试中影响小) |
- 使用 StringWriter 而非文件输出 |
减少 I/O |
总结
FreeMarker 模板单元测试是保障前端渲染逻辑正确性的关键。通过:
- 正确配置
Configuration
- 构造真实数据模型
- 渲染到
StringWriter
- 使用
AssertJ
断言输出
您可以高效、可靠地测试模板中的各种逻辑。遵循最佳实践,不仅能提升代码质量,还能在重构时提供强大信心。
提示:对于复杂前端项目,建议结合 Selenium 或 Playwright 进行端到端 UI 测试,与 FreeMarker 单元测试形成互补。