一、核心概念

  1. FreeMarker 模板引擎(freemarker.template.Configuration
    是 FreeMarker 的核心配置类,用于加载模板、设置全局参数(如编码、缓存策略)、管理共享变量等。

  2. 模板(Template
    通过 Configuration.getTemplate("template.ftl") 加载的 .ftl 文件,是执行渲染的主体。

  3. 数据模型(Map<String, Object>
    传递给模板的数据上下文,通常是一个 Map,包含模板中使用的变量、列表、对象等。

  4. 渲染输出(Writer
    模板渲染的结果输出到 Writer(如 StringWriter),可转换为字符串进行断言。

  5. 单元测试框架(JUnit / TestNG)
    使用 JUnit 5 或 TestNG 编写测试用例,结合断言库(如 AssertJ、Hamcrest)验证输出。

  6. 测试范围

    • 条件判断(#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, 张三 &amp; 张!</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,断言时检查转义后内容(如 &amp;
缓存导致修改模板不生效 测试环境未关闭缓存 设置 setTemplateLookupStrategy 或使用 StringTemplateLoader
NoClassDefFoundError 缺少 FreeMarker 依赖 检查 pom.xml 是否包含 freemarker 依赖

四、注意事项

  1. 模板路径必须在类路径下:使用 setClassForTemplateLoading 时,路径是相对于类的包路径或资源目录。
  2. 编码一致性:确保模板文件保存为 UTF-8,且 Configuration 中设置 setDefaultEncoding("UTF-8")
  3. 异常处理器设置:测试时建议使用 DEBUG_HANDLER,生产环境可用 RETHROW_HANDLER
  4. 避免缓存干扰:单元测试中建议关闭缓存或使用 StringTemplateLoader 临时加载模板字符串。
  5. HTML 转义安全:测试时验证 ?html 是否生效,防止 XSS。
  6. 宏与包含测试:若模板使用 #include,需确保被包含文件也在类路径下。

五、使用技巧

  1. 使用 StringTemplateLoader 测试片段
    适合测试宏或小片段,无需创建文件:

    StringTemplateLoader loader = new StringTemplateLoader();
    loader.putTemplate("test.ftl", "<#macro m>x</#macro><@m/>");
    configuration.setTemplateLoader(loader);
    
  2. 断言 HTML 结构(高级)
    使用 jsoup 解析 HTML 并断言结构:

    Document doc = Jsoup.parse(result);
    assertThat(doc.select("h1").text()).isEqualTo("Welcome, 张三 & 张!");
    
  3. 参数化测试(JUnit 5)
    对同一模板测试多组数据:

    @ParameterizedTest
    @MethodSource("provideUserData")
    void shouldRenderWithDifferentUsers(UserData data) { ... }
    
  4. 测试内建函数
    显式测试 ?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 模板单元测试是保障前端渲染逻辑正确性的关键。通过:

  1. 正确配置 Configuration
  2. 构造真实数据模型
  3. 渲染到 StringWriter
  4. 使用 AssertJ 断言输出

您可以高效、可靠地测试模板中的各种逻辑。遵循最佳实践,不仅能提升代码质量,还能在重构时提供强大信心。

提示:对于复杂前端项目,建议结合 SeleniumPlaywright 进行端到端 UI 测试,与 FreeMarker 单元测试形成互补。