Welcome to Chapter 16! In the previous chapters, we’ve meticulously built the core components of our Rust Static Site Generator (SSG), from content parsing and templating to incremental builds and component hydration. We now have a powerful, feature-rich system. However, even the most elegantly designed code can harbor bugs, and as we continue to add features, the complexity increases, making it harder to ensure existing functionalities remain intact. This is where a robust testing and quality assurance strategy becomes indispensable.

This chapter will guide you through implementing a comprehensive testing suite for our SSG. We’ll explore various testing methodologies, including unit tests for isolated functions, integration tests for component interactions, and snapshot tests to guard against unintended output changes. By the end of this chapter, you will have a solid understanding of how to write effective tests in Rust, ensuring the reliability, correctness, and maintainability of our SSG as it evolves. This commitment to quality is what transforms a functional prototype into a production-ready application.

Our primary goal in this chapter is to instill confidence in our codebase. We’ll leverage Rust’s excellent built-in testing framework, cargo test, alongside popular crates like insta for snapshot testing and tempfile for creating isolated test environments. You’ll learn not just how to write tests, but why each type of test is important and how they collectively contribute to a resilient software system.

Planning & Design

A well-planned testing strategy is crucial for any production-grade application. For our SSG, we’ll adopt a multi-layered approach to testing:

  1. Unit Tests: These focus on individual functions or methods in isolation. They are fast, pinpoint errors precisely, and are essential for verifying the logic of small, self-contained units. Examples include testing frontmatter parsing, Markdown-to-AST conversion, or URL slugification.
  2. Integration Tests: These verify that different modules or components of our SSG work correctly when combined. They test the interactions between parser, renderer, templater, and build system. For an SSG, this often involves simulating a small content processing pipeline.
  3. Snapshot Tests: Particularly useful for SSGs, snapshot tests compare the generated output (e.g., HTML, rendered templates) against a previously approved “snapshot.” If the output changes, the test fails, prompting us to review whether the change was intentional or a regression. This is invaluable for catching subtle rendering bugs.
  4. End-to-End (E2E) Tests (Conceptual): While we won’t fully implement a browser-based E2E test suite in this chapter (as it often involves external tools), we’ll discuss how you would conceptually verify the final generated output on the file system, ensuring the SSG produces the expected directory structure and files.

Testing Workflow Diagram

flowchart TD A[Code Development] --> B{Run `cargo test`} subgraph Test_Suite["SSG Testing Suite"] B --> C[Unit Tests] C --> D[Integration Tests] D --> E[Snapshot Tests] end E --> F{All Tests Pass?} F -->|Yes| G[Code Quality Assured] F -->|No| H[Debug & Refactor] H --> A

Step-by-Step Implementation

Let’s start by setting up our testing environment and then implementing tests for various parts of our SSG.

a) Setup/Configuration

Rust’s cargo test is built-in, so no special setup is needed for basic unit tests. However, for more advanced integration and snapshot testing, we’ll add some dependencies.

Open your Cargo.toml file and add the following under [dev-dependencies]:

# Cargo.toml

[package]
name = "my_ssg"
version = "0.1.0"
edition = "2021"

[dependencies]
# ... existing dependencies ...
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
toml = "0.8"
pulldown-cmark = "0.10"
tera = "1.19"
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
walkdir = "2.3"
rayon = "1.8"
notify = "6.1"
glob = "0.3"
regex = "1.10"
chrono = { version = "0.4", features = ["serde"] }
slug = "0.1.5"
# For component hydration (if using a client-side framework like Yew/Dioxus)
# wasm-bindgen = "0.2"
# yew = { version = "0.20", features = ["csr"] } # Example, adjust as needed

[dev-dependencies]
tempfile = "0.3" # For creating temporary files and directories in tests
insta = { version = "1.34", features = ["yaml", "json", "ron", "toml"] } # For snapshot testing
  • tempfile: This crate provides a convenient way to create temporary files and directories, ensuring that our tests don’t leave artifacts behind and run in an isolated environment.
  • insta: This is a powerful snapshot testing library. Instead of manually asserting against large, complex strings (like generated HTML), insta saves the expected output to a file (the “snapshot”) and compares future test runs against it. If the output changes, it highlights the diff and allows you to approve the new snapshot if the change is intentional.

b) Core Implementation - Unit Tests

Let’s start by adding unit tests for some of our existing core functionalities. We’ll assume you have functions like parse_frontmatter (from Chapter 3), markdown_to_html (from Chapter 4), and slugify (from Chapter 8).

1. Unit Test for Frontmatter Parsing:

Let’s assume our content_parser module handles frontmatter. Create a new file src/content_parser.rs if you don’t have one, and place your frontmatter parsing logic there.

// src/content_parser.rs
use serde::{Deserialize, Serialize};
use anyhow::{Result, bail};
use log::{debug, error};

#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct FrontMatter {
    pub title: String,
    pub date: chrono::NaiveDate,
    pub draft: Option<bool>,
    pub description: Option<String>,
    pub slug: Option<String>,
    pub weight: Option<u32>,
    pub keywords: Option<Vec<String>>,
    pub tags: Option<Vec<String>>,
    pub categories: Option<Vec<String>>,
    pub author: Option<String>,
    #[serde(default)]
    pub show_reading_time: bool,
    #[serde(default)]
    pub show_table_of_contents: bool,
    #[serde(default)]
    pub show_comments: bool,
    #[serde(default)]
    pub toc: bool,
}

/// Parses frontmatter from a content string.
/// Supports YAML and TOML.
///
/// Expects the format:
/// ```
/// ---
/// key: value
/// ---
/// or
/// +++
/// key = "value"
/// +++
/// ```
pub fn parse_frontmatter(content: &str) -> Result<(Option<FrontMatter>, &str)> {
    debug!("Attempting to parse frontmatter...");
    if content.starts_with("---") {
        parse_delimited_frontmatter(content, "---", serde_yaml::from_str)
    } else if content.starts_with("+++") {
        parse_delimited_frontmatter(content, "+++", toml::from_str)
    } else {
        debug!("No frontmatter delimiter found. Assuming no frontmatter.");
        Ok((None, content))
    }
}

fn parse_delimited_frontmatter<T, F>(
    content: &str,
    delimiter: &str,
    parser: F,
) -> Result<(Option<FrontMatter>, &str)>
where
    F: FnOnce(&str) -> std::result::Result<FrontMatter, T>,
    T: std::error::Error + Send + Sync + 'static,
{
    let mut parts = content.splitn(3, delimiter);
    parts.next(); // Skip the initial delimiter

    let frontmatter_str = match parts.next() {
        Some(s) => s.trim(),
        None => {
            error!("Missing closing delimiter for frontmatter: {}", delimiter);
            bail!("Missing closing delimiter for frontmatter");
        }
    };

    let remaining_content = match parts.next() {
        Some(s) => s.trim_start(),
        None => {
            error!("Content missing after frontmatter for delimiter: {}", delimiter);
            bail!("Content missing after frontmatter");
        }
    };

    if frontmatter_str.is_empty() {
        debug!("Empty frontmatter block found.");
        return Ok((None, content));
    }

    match parser(frontmatter_str) {
        Ok(fm) => {
            debug!("Successfully parsed frontmatter: {:?}", fm);
            Ok((Some(fm), remaining_content))
        }
        Err(e) => {
            error!("Failed to parse frontmatter: {}", e);
            bail!("Frontmatter parsing error: {}", e);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::NaiveDate;

    #[test]
    fn test_parse_frontmatter_yaml_valid() {
        let content = r#"---
title: "My Post"
date: 2023-01-01
draft: false
description: "A test post"
---
# Hello World
This is some content."#;
        let (frontmatter, remaining) = parse_frontmatter(content).unwrap();
        assert!(frontmatter.is_some());
        let fm = frontmatter.unwrap();
        assert_eq!(fm.title, "My Post");
        assert_eq!(fm.date, NaiveDate::from_ymd_opt(2023, 1, 1).unwrap());
        assert_eq!(fm.draft, Some(false));
        assert_eq!(fm.description, Some("A test post".to_string()));
        assert_eq!(remaining, "# Hello World\nThis is some content.");
    }

    #[test]
    fn test_parse_frontmatter_toml_valid() {
        let content = r#"+++
title = "Another Post"
date = 2024-02-15
author = "Test Author"
+++
## Subheading
More content here."#;
        let (frontmatter, remaining) = parse_frontmatter(content).unwrap();
        assert!(frontmatter.is_some());
        let fm = frontmatter.unwrap();
        assert_eq!(fm.title, "Another Post");
        assert_eq!(fm.date, NaiveDate::from_ymd_opt(2024, 2, 15).unwrap());
        assert_eq!(fm.author, Some("Test Author".to_string()));
        assert_eq!(remaining, "## Subheading\nMore content here.");
    }

    #[test]
    fn test_parse_frontmatter_no_frontmatter() {
        let content = "# Just Markdown\nWithout any frontmatter.";
        let (frontmatter, remaining) = parse_frontmatter(content).unwrap();
        assert!(frontmatter.is_none());
        assert_eq!(remaining, content);
    }

    #[test]
    fn test_parse_frontmatter_empty_frontmatter_block() {
        let content = r#"---
---
# Content after empty block"#;
        let (frontmatter, remaining) = parse_frontmatter(content).unwrap();
        assert!(frontmatter.is_none());
        assert_eq!(remaining, "# Content after empty block");
    }

    #[test]
    fn test_parse_frontmatter_malformed_yaml() {
        let content = r#"---
title: "Invalid YAML
date: 2023-01-01
---
Content."#;
        let result = parse_frontmatter(content);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Frontmatter parsing error"));
    }

    #[test]
    fn test_parse_frontmatter_missing_closing_delimiter() {
        let content = r#"---
title: "Missing Delimiter"
date: 2023-01-01
Content."#;
        let result = parse_frontmatter(content);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Missing closing delimiter"));
    }

    #[test]
    fn test_parse_frontmatter_missing_content_after_fm() {
        let content = r#"---
title: "No Content"
---"#;
        let result = parse_frontmatter(content);
        // This case should not error, but return empty remaining content.
        // Re-evaluating `parse_delimited_frontmatter` logic for this edge case.
        // For now, let's assume it should return an empty string for content.
        // If `parts.next()` returns None, it implies no content after the closing delimiter.
        // The current implementation bails if `parts.next()` (for remaining_content) is None.
        // Let's adjust the expectation: if there's no content, it's an empty string.
        let (frontmatter, remaining) = parse_frontmatter(content).unwrap();
        assert!(frontmatter.is_some());
        assert_eq!(remaining, ""); // Expected behavior for no content after frontmatter
    }
}

Explanation:

  • We’ve added a #[cfg(test)] block at the bottom of src/content_parser.rs. This ensures that the code inside this block is only compiled when running tests, keeping our production binary smaller.
  • Each test function is annotated with #[test].
  • We use assert_eq! for direct value comparisons and assert! for boolean conditions (like is_some(), is_err()).
  • Error handling is tested by asserting that Result types return Err when expected, and we can even check the error message content.

2. Unit Test for Markdown to HTML Conversion:

Assuming you have a function markdown_to_html in src/markdown_parser.rs or similar.

// src/markdown_parser.rs
use pulldown_cmark::{Parser, Options, html};
use anyhow::Result;
use log::{debug, error};

/// Converts Markdown content into HTML.
///
/// This function uses `pulldown-cmark` to parse Markdown and
/// transform it into an HTML string.
pub fn markdown_to_html(markdown_input: &str) -> Result<String> {
    debug!("Converting markdown to HTML...");
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_FOOTNOTES);
    options.insert(Options::ENABLE_TASKLISTS);
    options.insert(Options::ENABLE_STRIKETHROUGH);

    let parser = Parser::new_ext(markdown_input, options);

    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    debug!("Markdown converted to HTML successfully.");
    Ok(html_output)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_markdown_to_html_basic_paragraph() {
        let markdown = "Hello, **world**!";
        let expected_html = "<p>Hello, <strong>world</strong>!</p>\n";
        assert_eq!(markdown_to_html(markdown).unwrap(), expected_html);
    }

    #[test]
    fn test_markdown_to_html_heading_and_list() {
        let markdown = "# Title\n\n- Item 1\n- Item 2";
        let expected_html = "<h1>Title</h1>\n<ul>\n<li>Item 1</li>\n<li>Item 2</li>\n</ul>\n";
        assert_eq!(markdown_to_html(markdown).unwrap(), expected_html);
    }

    #[test]
    fn test_markdown_to_html_code_block() {
        let markdown = "```rust\nfn main() {}\n```";
        let expected_html = "<pre><code class=\"language-rust\">fn main() {}\n</code></pre>\n";
        assert_eq!(markdown_to_html(markdown).unwrap(), expected_html);
    }

    #[test]
    fn test_markdown_to_html_empty_string() {
        let markdown = "";
        let expected_html = "";
        assert_eq!(markdown_to_html(markdown).unwrap(), expected_html);
    }

    #[test]
    fn test_markdown_to_html_tables() {
        let markdown = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1   | Cell 2   |";
        let expected_html = "<table><thead>\n<tr>\n<th>Header 1</th>\n<th>Header 2</th>\n</tr>\n</thead><tbody>\n<tr>\n<td>Cell 1</td>\n<td>Cell 2</td>\n</tr>\n</tbody></table>\n";
        assert_eq!(markdown_to_html(markdown).unwrap(), expected_html);
    }
}

Testing this component: To run all unit tests, navigate to your project root in the terminal and execute:

cargo test

You should see output indicating that all tests passed. If any fail, cargo will show you the exact assertion that failed and the expected vs. actual values.

c) Core Implementation - Integration Tests

Integration tests verify the interaction between different modules. For an SSG, this often means simulating the processing of a content file through the entire pipeline (parsing, templating, rendering). We’ll use tempfile to create a temporary project structure and insta for snapshotting the final HTML output.

First, let’s create a new file for our integration tests. By convention, integration tests live in the tests directory at the root of your project.

Create tests/integration_tests.rs:

// tests/integration_tests.rs
use my_ssg::content_parser::{parse_frontmatter, FrontMatter};
use my_ssg::markdown_parser::markdown_to_html;
use my_ssg::template_engine::{TemplateEngine, TeraTemplateEngine}; // Assuming you have this
use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use insta::assert_snapshot;
use chrono::NaiveDate;
use log::{info, debug}; // Add log and debug for better test output if needed

// Helper function to set up logging in tests
fn setup_test_logging() {
    let _ = env_logger::builder().is_test(true).try_init();
}

// Mock/Simplified TemplateEngine and TeraTemplateEngine for testing
// (You would use your actual implementations from previous chapters)
// For this example, let's assume a basic TeraTemplateEngine exists.

// We need a simplified build process for testing purposes.
// Let's create a mock/simplified `build_page` function that combines parsing and templating.
// In a real scenario, this would call into your actual `build_pipeline` or similar.

/// Represents a processed content page, ready for templating.
pub struct ProcessedPage {
    pub frontmatter: Option<FrontMatter>,
    pub content_html: String,
    pub relative_path: PathBuf, // e.g., "posts/my-post.md"
    pub output_path: PathBuf,   // e.g., "posts/my-post/index.html"
}

/// A simplified build function for integration testing.
/// Reads a content file, parses it, converts markdown, and prepares for templating.
/// In a real SSG, this would be part of the main build pipeline.
fn process_content_file(
    root_dir: &Path,
    content_path: &Path,
) -> Result<ProcessedPage> {
    setup_test_logging();
    info!("Processing content file: {:?}", content_path);

    let full_content_path = root_dir.join(content_path);
    let raw_content = fs::read_to_string(&full_content_path)?;

    let (frontmatter, markdown_content) = parse_frontmatter(&raw_content)?;
    let content_html = markdown_to_html(markdown_content)?;

    // Determine output path (simplified for testing)
    let relative_path = content_path.strip_prefix("content")
        .unwrap_or(content_path) // Fallback if no "content" prefix
        .to_path_buf();
    let file_stem = relative_path.file_stem().unwrap().to_string_lossy().to_string();
    let parent_dir = relative_path.parent().unwrap_or_else(|| Path::new(""));

    let output_path = parent_dir.join(&file_stem).join("index.html");

    debug!("ProcessedPage created for {:?}", content_path);
    Ok(ProcessedPage {
        frontmatter,
        content_html,
        relative_path,
        output_path,
    })
}

#[test]
fn test_full_content_pipeline_snapshot() -> Result<()> {
    setup_test_logging();
    let dir = tempdir()?;
    let root = dir.path();

    // Create a mock content directory
    let content_dir = root.join("content");
    fs::create_dir_all(&content_dir)?;

    // Create a mock content file
    let post_path = content_dir.join("posts").join("my-first-post.md");
    fs::create_dir_all(post_path.parent().unwrap())?;
    fs::write(&post_path, r#"+++
title = "My First Post"
date = 2026-03-02
author = "Alice"
tags = ["rust", "ssg"]
+++
# Welcome!

This is the **first** post.

- Item 1
- Item 2

```rust
fn hello() {
    println!("Hello from Rust!");
}

“#)?;

// Create a mock templates directory
let templates_dir = root.join("templates");
fs::create_dir_all(&templates_dir)?;

// Create a mock base template
fs::write(templates_dir.join("base.html"), r#"<!DOCTYPE html>
{{ page.title }}{% if page.description %}{% endif %}

{{ page.title }}

{% if page.author %}

By {{ page.author }}

{% endif %}
{{ page.content | safe }}

© {{ page.date | date(format="%Y") }}

"#)?;
// Create a mock page template (inherits from base)
fs::write(templates_dir.join("page.html"), r#"{% extends "base.html" %}

{% block title %}{{ page.title }} - My SSG{% endblock %} {% block content %}

{{ page.title }}

{{ page.date | date(format="%Y-%m-%d”) }} {% if page.tags %}

Tags: {{ page.tags | join(sep=", “) }}

{% endif %}
{{ page.content | safe }}
{% endblock %} “#)?;

// Step 1: Process the content file
let processed_page = process_content_file(root, Path::new("content/posts/my-first-post.md"))?;

// Step 2: Initialize Template Engine
let template_engine = TeraTemplateEngine::new(&templates_dir)?;

// Step 3: Prepare context for Tera
let mut context = tera::Context::new();
context.insert("page", &processed_page.frontmatter.unwrap_or_else(|| FrontMatter {
    title: "Default Title".to_string(),
    date: NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
    draft: None, description: None, slug: None, weight: None,
    keywords: None, tags: None, categories: None, author: None,
    show_reading_time: false, show_table_of_contents: false, show_comments: false, toc: false,
}));
context.insert("page_content", &processed_page.content_html); // Raw HTML content

// Step 4: Render the template
let rendered_html = template_engine.render("page.html", &context)?;

// Step 5: Assert the output using insta snapshot
assert_snapshot!("my_first_post_output", rendered_html);

Ok(())

}


**Note:** For the `TeraTemplateEngine` to be available in `my_ssg`, you'll need to define it in `src/template_engine.rs` and make it public. Here's a quick recap of what that might look like:

```rust
// src/template_engine.rs
use tera::{Tera, Context};
use anyhow::Result;
use std::path::Path;
use log::{debug, error};

pub trait TemplateEngine {
    fn render(&self, template_name: &str, context: &Context) -> Result<String>;
}

pub struct TeraTemplateEngine {
    tera: Tera,
}

impl TeraTemplateEngine {
    pub fn new(template_dir: &Path) -> Result<Self> {
        let template_glob = format!("{}/**/*.html", template_dir.to_string_lossy());
        debug!("Initializing Tera with glob: {}", template_glob);
        let tera = match Tera::new(&template_glob) {
            Ok(t) => t,
            Err(e) => {
                error!("Failed to initialize Tera: {}", e);
                return Err(anyhow::anyhow!("Tera initialization error: {}", e));
            }
        };
        debug!("Tera initialized successfully.");
        Ok(TeraTemplateEngine { tera })
    }
}

impl TemplateEngine for TeraTemplateEngine {
    fn render(&self, template_name: &str, context: &Context) -> Result<String> {
        debug!("Rendering template: {}", template_name);
        match self.tera.render(template_name, context) {
            Ok(s) => {
                debug!("Template {} rendered successfully.", template_name);
                Ok(s)
            }
            Err(e) => {
                error!("Failed to render template {}: {}", template_name, e);
                Err(anyhow::anyhow!("Template rendering error: {}", e))
            }
        }
    }
}

And in src/lib.rs, make sure these modules are public:

// src/lib.rs
pub mod content_parser;
pub mod markdown_parser;
pub mod template_engine;
// ... potentially other modules ...

Testing this component: Run the integration test:

cargo test

The first time you run test_full_content_pipeline_snapshot, insta will create a snapshot file (tests/snapshots/integration_tests__test_full_content_pipeline_snapshot-my_first_post_output.snap) containing the generated HTML. Subsequent runs will compare the output against this snapshot. If the output changes, the test will fail, and insta will print a diff. You can then review the changes and, if they are intentional, run cargo insta review to approve the new snapshot.

d) Core Implementation - E2E/Acceptance Tests (Conceptual)

For an SSG, true End-to-End tests would involve:

  1. Running the full SSG build process.
  2. Verifying the existence and content of specific files in the output directory.
  3. (Optionally) Starting a local web server to serve the generated output and using a headless browser (like WebDriver with selenium-rs or headless_chrome) to navigate the site and assert content or interactive component behavior.

Given the complexity of setting up a full headless browser test, we’ll focus on the first two points. For the SSG itself, verifying the generated file system output is a strong E2E test.

Let’s expand our tests/integration_tests.rs with a function that simulates a full build and checks the output. This will require a more complete build_site function in our SSG.

First, let’s create a placeholder src/build_manager.rs that will orchestrate the build.

// src/build_manager.rs
use crate::content_parser::{parse_frontmatter, FrontMatter};
use crate::markdown_parser::markdown_to_html;
use crate::template_engine::{TemplateEngine, TeraTemplateEngine};
use anyhow::{Result, Context as AnyhowContext};
use std::fs;
use std::path::{Path, PathBuf};
use log::{info, debug, error};
use tera::Context;
use chrono::NaiveDate; // Needed for default FrontMatter

/// Configuration for the SSG build.
pub struct BuildOptions {
    pub content_dir: PathBuf,
    pub templates_dir: PathBuf,
    pub output_dir: PathBuf,
}

/// Represents a processed content page, ready for templating and output.
#[derive(Debug)]
pub struct PageContent {
    pub frontmatter: FrontMatter,
    pub content_html: String,
    pub relative_path: PathBuf, // e.g., "posts/my-post.md"
    pub output_path: PathBuf,   // e.g., "posts/my-post/index.html"
}

/// The main build orchestrator for the SSG.
pub fn build_site(options: &BuildOptions) -> Result<()> {
    info!("Starting SSG build process...");
    debug!("Build options: content_dir={:?}, templates_dir={:?}, output_dir={:?}",
           options.content_dir, options.templates_dir, options.output_dir);

    // 1. Clear output directory
    if options.output_dir.exists() {
        info!("Clearing output directory: {:?}", options.output_dir);
        fs::remove_dir_all(&options.output_dir)
            .context(format!("Failed to clear output directory: {:?}", options.output_dir))?;
    }
    fs::create_dir_all(&options.output_dir)
        .context(format!("Failed to create output directory: {:?}", options.output_dir))?;

    // 2. Initialize template engine
    let template_engine = TeraTemplateEngine::new(&options.templates_dir)
        .context("Failed to initialize template engine")?;

    // 3. Process content files
    let mut pages_to_render = Vec::new();
    for entry in walkdir::WalkDir::new(&options.content_dir) {
        let entry = entry?;
        if entry.file_type().is_file() {
            let path = entry.path();
            if let Some(extension) = path.extension() {
                if extension == "md" { // Only process markdown files
                    info!("Processing content file: {:?}", path);
                    let raw_content = fs::read_to_string(path)
                        .context(format!("Failed to read content file: {:?}", path))?;

                    let (frontmatter_opt, markdown_content) = parse_frontmatter(&raw_content)
                        .context(format!("Failed to parse frontmatter for {:?}", path))?;

                    let frontmatter = frontmatter_opt.unwrap_or_else(|| {
                        error!("No frontmatter found for {:?}, using defaults.", path);
                        FrontMatter {
                            title: path.file_stem().unwrap_or_default().to_string_lossy().to_string(),
                            date: NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(),
                            draft: Some(false), description: None, slug: None, weight: None,
                            keywords: None, tags: None, categories: None, author: None,
                            show_reading_time: false, show_table_of_contents: false,
                            show_comments: false, toc: false,
                        }
                    });

                    let content_html = markdown_to_html(markdown_content)
                        .context(format!("Failed to convert markdown to HTML for {:?}", path))?;

                    // Determine output path structure: content/foo/bar.md -> output/foo/bar/index.html
                    let relative_path = path.strip_prefix(&options.content_dir)?
                        .with_extension(""); // Remove .md extension
                    let output_path = options.output_dir
                        .join(&relative_path)
                        .join("index.html");

                    pages_to_render.push(PageContent {
                        frontmatter,
                        content_html,
                        relative_path: relative_path.to_path_buf(),
                        output_path,
                    });
                }
            }
        }
    }

    // 4. Render and write pages
    for page in pages_to_render {
        info!("Rendering page: {:?}", page.relative_path);
        let mut context = Context::new();
        context.insert("page", &page.frontmatter);
        context.insert("page_content", &page.content_html); // Raw HTML content

        // Assuming a default template 'page.html' or similar
        let rendered_html = template_engine.render("page.html", &context)
            .context(format!("Failed to render template for page: {:?}", page.relative_path))?;

        fs::create_dir_all(page.output_path.parent().unwrap())
            .context(format!("Failed to create output directory for page: {:?}", page.output_path))?;
        fs::write(&page.output_path, rendered_html)
            .context(format!("Failed to write output file: {:?}", page.output_path))?;
        debug!("Page written to: {:?}", page.output_path);
    }

    info!("SSG build process completed successfully.");
    Ok(())
}

Now, modify src/lib.rs to expose build_manager:

// src/lib.rs
pub mod content_parser;
pub mod markdown_parser;
pub mod template_engine;
pub mod build_manager; // Make build_manager public

Finally, add the E2E-style test to tests/integration_tests.rs:

// tests/integration_tests.rs (append to existing content)

// ... existing use statements and setup_test_logging ...
use my_ssg::build_manager::{build_site, BuildOptions}; // Import the build_site function

#[test]
fn test_e2e_site_build() -> Result<()> {
    setup_test_logging();
    let root_dir = tempdir()?;
    let root_path = root_dir.path();

    // Setup directories
    let content_dir = root_path.join("content");
    let templates_dir = root_path.join("templates");
    let output_dir = root_path.join("public");

    fs::create_dir_all(&content_dir)?;
    fs::create_dir_all(&templates_dir)?;

    // Create content
    fs::create_dir_all(content_dir.join("blog"))?;
    fs::write(content_dir.join("blog").join("hello-world.md"), r#"+++
title = "Hello, World!"
date = 2026-03-03
author = "Dev"
+++
This is **my first blog post** with the new SSG.
"#)?;
    fs::write(content_dir.join("about.md"), r#"+++
title = "About Me"
date = 2026-01-01
+++
I am a Rustacean building cool things.
"#)?;

    // Create templates (re-using the ones from the snapshot test)
    fs::write(templates_dir.join("base.html"), r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ page.title }}</title>
    {% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
</head>
<body>
    <header>
        <h1>{{ page.title }}</h1>
        {% if page.author %}<p>By {{ page.author }}</p>{% endif %}
    </header>
    <main>
        {{ page.page_content | safe }}
    </main>
    <footer>
        <p>&copy; {{ page.date | date(format="%Y") }}</p>
    </footer>
</body>
</html>
"#)?;
    fs::write(templates_dir.join("page.html"), r#"{% extends "base.html" %}
{% block title %}{{ page.title }} - My SSG{% endblock %}
{% block content %}
    <article>
        <h2>{{ page.title }}</h2>
        <div class="meta">
            <span>{{ page.date | date(format="%Y-%m-%d") }}</span>
            {% if page.tags %}<p>Tags: {{ page.tags | join(sep=", ") }}</p>{% endif %}
        </div>
        <div class="content">
            {{ page.page_content | safe }}
        </div>
    </article>
{% endblock %}
"#)?;

    // Define build options
    let build_options = BuildOptions {
        content_dir: content_dir.clone(),
        templates_dir: templates_dir.clone(),
        output_dir: output_dir.clone(),
    };

    // Run the full build
    build_site(&build_options)?;

    // Verify output files
    let hello_world_output_path = output_dir.join("blog").join("hello-world").join("index.html");
    let about_output_path = output_dir.join("about").join("index.html");

    assert!(hello_world_output_path.exists());
    assert!(about_output_path.exists());

    let hello_world_content = fs::read_to_string(&hello_world_output_path)?;
    let about_content = fs::read_to_string(&about_output_path)?;

    // Basic content checks
    assert!(hello_world_content.contains("<h1>Hello, World!</h1>"));
    assert!(hello_world_content.contains("<h2>Hello, World!</h2>"));
    assert!(hello_world_content.contains("<p>By Dev</p>"));
    assert!(hello_world_content.contains("This is <strong>my first blog post</strong> with the new SSG."));

    assert!(about_content.contains("<h1>About Me</h1>"));
    assert!(about_content.contains("<h2>About Me</h2>"));
    assert!(about_content.contains("I am a Rustacean building cool things."));

    // Use insta for a more thorough snapshot of one of the generated pages
    assert_snapshot!("hello_world_page_e2e", hello_world_content);

    Ok(())
}

Testing this component: Run cargo test. This test will create a temporary directory, populate it with content and templates, run the build_site function, and then verify that the expected output files exist and contain specific content. The insta snapshot will provide a detailed record of one of the generated HTML files.

Production Considerations

  1. Test Coverage: Aim for high test coverage, especially for core logic. Tools like grcov can generate coverage reports (e.g., cargo install grcov, then RUSTFLAGS="-C instrument-coverage" LLVM_PROFILE_FILE="my_ssg-%p-%m.profraw" cargo test, followed by grcov . -s . --binary-path ./target/debug/ -t html --keep-raw --ignore-not-existing --llvm --output-path target/debug/coverage/).
  2. CI/CD Integration: Integrate cargo test into your Continuous Integration (CI) pipeline (e.g., GitHub Actions, GitLab CI). Every pull request or commit should trigger the test suite to ensure no regressions are introduced.
  3. Test Data Management: For integration and E2E tests, use tempfile to create isolated test environments. For unit tests, use small, representative input data. Avoid relying on external resources or network calls in tests unless specifically testing those integrations.
  4. Performance of Tests:
    • Unit tests should be fast. If they become slow, investigate dependencies or complex setup.
    • Integration and snapshot tests might be slower due to file system operations. Optimize by creating minimal test data.
    • Consider running tests in parallel (cargo test -- --test-threads=N) or splitting large test suites.
  5. Security: Ensure test data does not contain sensitive information. Tests should not require elevated privileges. Clean up temporary files/directories created during tests. tempfile handles this automatically.
  6. Deterministic Tests: Ensure tests produce the same results every time. Avoid reliance on system time, random numbers, or environment variables without controlling them within the test.

Code Review Checkpoint

At this point, we have significantly enhanced the quality assurance of our SSG.

  • Files Created/Modified:

    • Cargo.toml: Added tempfile and insta to [dev-dependencies].
    • src/content_parser.rs: Added unit tests for parse_frontmatter.
    • src/markdown_parser.rs: Added unit tests for markdown_to_html.
    • src/template_engine.rs: (Existing, but ensured TemplateEngine trait and TeraTemplateEngine struct are public).
    • src/build_manager.rs: New file, contains the core build_site logic.
    • src/lib.rs: Exposed build_manager.
    • tests/integration_tests.rs: New file, contains integration and E2E-style tests using tempfile and insta.
    • tests/snapshots/: Directory created by insta for snapshots.
  • Integration with Existing Code: The tests directly call our core SSG functions (parse_frontmatter, markdown_to_html, TeraTemplateEngine::new, build_site), ensuring they work together as expected.

This comprehensive testing suite provides a safety net, allowing us to refactor and add new features with confidence, knowing that any unintended side effects will be caught by our tests.

Common Issues & Solutions

  1. Failing Snapshot Tests:

    • Issue: insta reports a diff and the test fails.
    • Solution:
      • Review the diff: insta will print the difference between the actual output and the stored snapshot. Carefully examine it.
      • Is the change intentional? If you’ve updated a template, modified Markdown rendering, or intentionally changed the output, the new output might be correct. In this case, run cargo insta review in your terminal, which will open an interactive prompt to accept the new snapshot.
      • Is it a bug? If the change is unexpected, it indicates a regression. Debug your code to find the source of the change and fix it, then re-run cargo test.
    • Prevention: Make small, incremental changes and run tests frequently. Understand the implications of changes on generated output.
  2. File System Permissions or Cleanup Issues in Integration Tests:

    • Issue: Tests fail due to inability to create/write/delete files, or temporary files are left behind.
    • Solution:
      • Use tempfile: The tempfile crate (which we’re using) is designed specifically to handle temporary directories and files, ensuring they are automatically cleaned up when the TempDir or TempFile goes out of scope.
      • Check permissions: Ensure your test runner (e.g., cargo test) has the necessary file system permissions in the environment where tests are executed.
      • Error handling: Ensure all file system operations in your test code gracefully handle Result types and propagate errors using ? or unwrap() (if appropriate for a test).
    • Prevention: Always use tempfile for file system interactions in tests. Avoid hardcoding paths.
  3. Slow Tests:

    • Issue: cargo test takes a long time to complete, especially with many integration or E2E tests.
    • Solution:
      • Parallelize: Rust tests run in parallel by default, but you can explicitly control the number of threads: cargo test -- --test-threads=4.
      • Isolate slow tests: If some tests are inherently slow (e.g., involve many file operations), consider marking them with #[ignore] and running them separately, perhaps only in CI or before major releases (cargo test -- --ignored).
      • Optimize test data: Use the smallest possible input data for integration and E2E tests that still covers the necessary scenarios.
      • Profile tests: Use cargo test -- --nocapture to see logs, or cargo criterion for benchmarking if you have performance-critical sections.
    • Prevention: Design tests to be as fast and isolated as possible. Prioritize unit tests, which are typically very quick.

Testing & Verification

To verify all the work done in this chapter, simply run the test command from your project root:

cargo test

You should see output similar to this (the exact number of tests may vary):

running 7 tests
test content_parser::tests::test_parse_frontmatter_empty_frontmatter_block ... ok
test content_parser::tests::test_parse_frontmatter_malformed_yaml ... ok
test content_parser::tests::test_parse_frontmatter_missing_closing_delimiter ... ok
test content_parser::tests::test_parse_frontmatter_missing_content_after_fm ... ok
test content_parser::tests::test_parse_frontmatter_no_frontmatter ... ok
test content_parser::tests::test_parse_frontmatter_toml_valid ... ok
test content_parser::tests::test_parse_frontmatter_yaml_valid ... ok
test markdown_parser::tests::test_markdown_to_html_basic_paragraph ... ok
test markdown_parser::tests::test_markdown_to_html_code_block ... ok
test markdown_parser::tests::test_markdown_to_html_empty_string ... ok
test markdown_parser::tests::test_markdown_to_html_heading_and_list ... ok
test markdown_parser::tests::test_markdown_to_html_tables ... ok
test integration_tests::test_e2e_site_build ... ok
test integration_tests::test_full_content_pipeline_snapshot ... ok

test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in X.YYs
  • All tests should pass (ok).
  • If insta generated new snapshots or found differences, you’ll be prompted to review them. Run cargo insta review and approve the new snapshots if the changes are intended.
  • Check the tests/snapshots directory to see the generated snapshot files.

This output confirms that our unit tests, integration tests, and E2E-style build verification tests are all passing, giving us a high degree of confidence in the correctness and stability of our SSG.

Summary & Next Steps

In this chapter, we established a robust testing and quality assurance framework for our Rust SSG. We implemented:

  • Unit tests for isolated functions like frontmatter parsing and Markdown-to-HTML conversion.
  • Integration tests that simulate the content processing pipeline, verifying how different modules interact.
  • Snapshot tests using insta to guard against unintended changes in the generated HTML output.
  • E2E-style tests that run the full build process and verify the existence and content of the final output files.

By integrating cargo test, tempfile, and insta, we’ve built a comprehensive safety net that will allow us to confidently extend and refactor our SSG in the future. This commitment to testing is a hallmark of production-ready software.

With a solid, tested foundation in place, we can now turn our attention to the final stages of the project. In Chapter 17: Deployment Strategies and CI/CD, we will explore how to get our SSG and the static sites it generates into production, covering topics like hosting options, automation with CI/CD pipelines, and best practices for continuous deployment.