Fresh Notes
Markdown 콘텐츠 로더 테스트하기: Deno 임시 파일로 정상·초안·오류 검증하기 | Fresh Notes

Deno

Markdown 콘텐츠 로더 테스트하기: Deno 임시 파일로 정상·초안·오류 검증하기

Deno.test와 임시 Markdown 파일로 콘텐츠 로더를 검증합니다. 정상 글, 초안, 깨진 YAML, 읽기 권한 오류와 목록 정렬을 실제 파일 시스템에서 확인하고 테스트 데이터를 격리합니다.

2026.11.04·Deno·6 min read

콘텐츠 로더만 빠르게 확인하고 싶을 때

Fresh Notes의 글 상세 페이지는 getPost()로 Markdown 파일을 읽습니다.

목록과 검색, 사이트맵은 getPosts()를 사용하고, 글 하단의 연재 목차는 getSeriesPosts()를 사용합니다.

이 함수들이 잘못된 결과를 반환하면 여러 페이지에서 문제가 나타납니다.

13편의 HTTP 테스트는 빌드된 서버가 요청에 어떻게 응답하는지 확인했습니다. 이번에는 그보다 안쪽에 있는 콘텐츠 로더를 직접 검사하겠습니다.

Markdown 파일
→ 콘텐츠 로더
→ 메타데이터·본문·목록

빌드나 브라우저 실행 없이 파일 읽기부터 파싱, 렌더링, 공개 여부와 정렬까지 확인하는 테스트입니다.

11편의 메타데이터 검증과도 역할이 다릅니다.

검사 확인할 질문
메타데이터 검증 작성한 글이 필드 규칙을 만족하는가?
콘텐츠 로더 테스트 파일을 읽고 처리하는 코드가 약속한 결과를 반환하는가?
HTTP 테스트 라우트가 올바른 상태·헤더·본문으로 응답하는가?

이번 코드는 프로젝트의 임시 복사본에서 작성하고 실행했습니다. 실제 프로젝트의 글 파일이나 로더는 수정하지 않았습니다.

실험에는 Deno 2.8.3, @std/front-matter 1.0.9, @deno/gfm 0.12.0을 사용했습니다.

실제 글을 테스트 데이터로 사용하지 않습니다

가장 간단한 테스트는 기존 글을 읽는 것입니다.

const post = await getPost(
  "fresh-markdown-blog",
);

하지만 이 방식에는 콘텐츠 변경이 테스트 결과에 영향을 준다는 문제가 있습니다.

글 제목을 수정하거나, 초안으로 바꾸거나, 파일 이름을 변경하면 테스트도 함께 바뀝니다. 깨진 YAML을 확인하려고 실제 글 파일을 잘못된 상태로 만들 수도 없습니다.

따라서 테스트마다 작은 Markdown 파일을 직접 만들겠습니다.

테스트 시작
→ 전용 임시 디렉터리 생성
→ 필요한 글 파일 작성
→ 콘텐츠 로더 호출
→ 결과 검사
→ 임시 디렉터리 삭제

파일 읽기 함수를 가짜 구현으로 바꾸지 않습니다. 실제 파일 시스템과 실제 파서를 사용하되, 데이터만 테스트 전용으로 분리합니다.

로더가 디렉터리를 인자로 받도록 바꿉니다

현재 로더의 콘텐츠 경로는 고정되어 있습니다.

const POSTS_DIR = "./content/posts";

이 기본값은 유지하면서, 테스트에서는 다른 디렉터리를 전달할 수 있게 하겠습니다.

getPost("hello");
getPost("hello", testDirectory);

기존 호출부는 수정하지 않아도 됩니다.

utils/posts.ts에서 기존 import, 타입, 읽기 시간 계산 함수는 유지하고 다음 세 함수를 교체합니다.

export async function getPost(
  slug: string,
  postsDir = POSTS_DIR,
): Promise<Post | null> {
  let source: string;

  try {
    source = await Deno.readTextFile(
      `${postsDir}/${slug}.md`,
    );
  } catch (error) {
    if (error instanceof Deno.errors.NotFound) {
      return null;
    }

    throw error;
  }

  const { attrs, body } = extractYaml<PostMeta>(
    source,
  );

  if (attrs.draft) {
    return null;
  }

  return {
    slug,
    meta: attrs,
    html: render(body),
    readingTime: estimateReadingTime(body),
  };
}

export async function getPosts(
  postsDir = POSTS_DIR,
): Promise<PostSummary[]> {
  const posts: PostSummary[] = [];

  for await (
    const entry of Deno.readDir(postsDir)
  ) {
    if (
      !entry.isFile ||
      !entry.name.endsWith(".md")
    ) {
      continue;
    }

    const slug = entry.name.replace(
      /\.md$/,
      "",
    );

    const source = await Deno.readTextFile(
      `${postsDir}/${entry.name}`,
    );

    const { attrs } = extractYaml<PostMeta>(
      source,
    );

    if (attrs.draft) {
      continue;
    }

    posts.push({
      slug,
      meta: attrs,
    });
  }

  return posts.sort(comparePostsByNewest);
}

export async function getSeriesPosts(
  postsDir = POSTS_DIR,
): Promise<PostSummary[]> {
  return sortSeriesPosts(
    await getPosts(postsDir),
  );
}

getSeriesPosts()에서도 같은 디렉터리를 getPosts()에 전달해야 합니다.

이 부분을 빠뜨리면 테스트에서 연재 목록을 확인할 때 실제 콘텐츠 디렉터리를 읽게 됩니다.

이번 변경은 경로를 선택할 수 있게 하는 것입니다. 12편에서 정한 오류 처리는 그대로 유지합니다.

파일 없음
→ null

초안
→ null

그 밖의 읽기 오류
→ 예외 전달

파싱 오류
→ 예외 전달

테스트를 위해 작업 디렉터리를 바꾸는 대신, 함수에 필요한 경로를 명시적으로 전달하도록 했습니다.

테스트 파일을 작성합니다

utils/posts_test.ts를 만듭니다.

아래는 전체 코드입니다.

import {
  assert,
  assertEquals,
  assertRejects,
} from "jsr:@std/assert@1.0.19";

import {
  getPost,
  getPosts,
  getSeriesPosts,
} from "./posts.ts";

function markdown(
  overrides: Record<string, unknown> = {},
  body = "## Test heading\n\nA **saved** paragraph.",
) {
  const meta = {
    title: "Fixture post",
    description:
      "콘텐츠 로더 테스트용 글입니다.",
    date: "2026-11-04",
    category: "Deno",
    tags: ["Deno", "Testing"],
    draft: false,
    ...overrides,
  };

  return `---
${JSON.stringify(meta)}
---

${body}`;
}

async function withPosts(
  files: Record<string, string>,
  run: (dir: string) => Promise<void>,
) {
  await Deno.mkdir("./.test-tmp", {
    recursive: true,
  });

  const dir = await Deno.makeTempDir({
    dir: "./.test-tmp",
    prefix: "posts-",
  });

  try {
    for (
      const [name, source] of Object.entries(
        files,
      )
    ) {
      await Deno.writeTextFile(
        `${dir}/${name}`,
        source,
      );
    }

    await run(dir);
  } finally {
    await Deno.remove(dir, {
      recursive: true,
    });
  }
}

Deno.test(
  "getPost: 메타데이터와 HTML을 반환한다",
  async () => {
    await withPosts(
      {
        "hello.md": markdown(),
      },
      async (dir) => {
        const post = await getPost(
          "hello",
          dir,
        );

        assert(post);

        assertEquals(
          post.slug,
          "hello",
        );

        assertEquals(
          post.meta.title,
          "Fixture post",
        );

        assertEquals(
          post.meta.date,
          "2026-11-04",
        );

        assertEquals(
          post.meta.tags,
          ["Deno", "Testing"],
        );

        assert(
          post.html.includes(
            "<strong>saved</strong>",
          ),
        );

        assert(
          !post.html.includes(
            "콘텐츠 로더 테스트용 글입니다.",
          ),
        );
      },
    );
  },
);

Deno.test(
  "getPost: 없는 글은 null이다",
  async () => {
    await withPosts({}, async (dir) => {
      assertEquals(
        await getPost("missing", dir),
        null,
      );
    });
  },
);

Deno.test(
  "getPost: 초안은 null이다",
  async () => {
    await withPosts(
      {
        "draft.md": markdown({
          draft: true,
        }),
      },
      async (dir) => {
        assertEquals(
          await getPost("draft", dir),
          null,
        );
      },
    );
  },
);

Deno.test(
  "깨진 YAML은 상세와 목록에서 예외로 전달된다",
  async () => {
    await withPosts(
      {
        "broken.md":
          "---\ntitle: [broken\n---\nBody",
      },
      async (dir) => {
        await assertRejects(
          () => getPost("broken", dir),
        );

        await assertRejects(
          () => getPosts(dir),
        );
      },
    );
  },
);

Deno.test({
  name:
    "getPost: 읽기 권한 오류를 null로 숨기지 않는다",

  permissions: {
    read: false,
    write: ["./.test-tmp"],
  },

  async fn() {
    await withPosts(
      {
        "hello.md": markdown(),
      },
      async (dir) => {
        await assertRejects(
          () => getPost("hello", dir),
          Deno.errors.NotCapable,
        );
      },
    );
  },
});

Deno.test(
  "getPosts: 초안, 다른 확장자, 디렉터리를 제외한다",
  async () => {
    await withPosts(
      {
        "published.md": markdown(),

        "draft.md": markdown({
          draft: true,
        }),

        "notes.txt": "메모",
      },
      async (dir) => {
        await Deno.mkdir(
          `${dir}/nested.md`,
        );

        const posts = await getPosts(dir);

        assertEquals(
          posts.map((post) => post.slug),
          ["published"],
        );
      },
    );
  },
);

Deno.test(
  "파일 메타데이터를 최신순과 연재순에 연결한다",
  async () => {
    await withPosts(
      {
        "first.md": markdown({
          order: 1,
        }),

        "second.md": markdown({
          order: 2,
        }),

        "third.md": markdown({
          date: "2026-11-05",
          order: 3,
        }),

        "standalone.md": markdown({
          date: "2026-11-06",
        }),
      },
      async (dir) => {
        const newest = await getPosts(dir);

        assertEquals(
          newest.map((post) => post.slug),
          [
            "standalone",
            "third",
            "second",
            "first",
          ],
        );

        const series = await getSeriesPosts(
          dir,
        );

        assertEquals(
          series.map((post) => post.slug),
          [
            "first",
            "second",
            "third",
          ],
        );
      },
    );
  },
);

Deno.test(
  "getPosts: 빈 디렉터리는 빈 목록이다",
  async () => {
    await withPosts({}, async (dir) => {
      assertEquals(
        await getPosts(dir),
        [],
      );
    });
  },
);

Deno.test()로 테스트를 등록하고, 비동기 함수의 예외는 assertRejects()로 확인합니다. Deno 테스트 문서

테스트 데이터를 만드는 markdown()은 공통 메타데이터를 준비하고, 각 테스트에서 필요한 필드만 덮어씁니다.

markdown({ draft: true });
markdown({ order: 2 });

Front Matter에는 이 파서에서 읽을 수 있는 JSON 객체 표현을 사용했습니다. JSON.stringify()가 문자열과 boolean을 구분해 주므로, 정상 테스트 데이터를 만들 때 날짜나 draft의 따옴표를 매번 직접 관리하지 않아도 됩니다.

깨진 YAML 테스트만 의도적으로 잘못된 문자열을 별도로 작성합니다.

임시 디렉터리는 테스트마다 새로 만듭니다

withPosts()의 핵심은 Deno.makeTempDir()입니다.

const dir = await Deno.makeTempDir({
  dir: "./.test-tmp",
  prefix: "posts-",
});

.test-tmp 아래에 테스트마다 서로 다른 디렉터리를 만듭니다.

.test-tmp/
├── posts-서로다른값/
└── posts-또다른값/

Deno는 임시 디렉터리를 생성하지만, 사용이 끝난 디렉터리를 정리하는 것은 호출자의 책임입니다. Deno makeTempDir 문서

따라서 정리는 finally에 둡니다.

try {
  // 파일 작성과 테스트
} finally {
  await Deno.remove(dir, {
    recursive: true,
  });
}

검사에 실패해 예외가 발생해도 해당 임시 디렉터리를 제거합니다. 이번 실행에서도 테스트용 하위 디렉터리가 정리된 것을 확인했습니다.

부모인 .test-tmp 디렉터리는 남을 수 있으므로 .gitignore에 다음 항목을 추가합니다.

.test-tmp/

정상 글에서는 파싱과 렌더링의 연결을 확인합니다

첫 번째 테스트는 단순히 객체가 반환되는지만 보지 않습니다.

파일 이름
→ slug

Front Matter
→ meta

Markdown 본문
→ html

세 경로가 연결되는지 확인합니다.

특히 다음 검사는 Markdown 렌더링이 실제로 수행되었는지 봅니다.

assert(
  post.html.includes(
    "<strong>saved</strong>",
  ),
);

Front Matter의 설명이 본문에 섞이지 않는지도 확인합니다.

assert(
  !post.html.includes(
    "콘텐츠 로더 테스트용 글입니다.",
  ),
);

HTML 전체를 문자열로 고정하지는 않았습니다. 렌더러가 만드는 부가 속성까지 모두 비교하기보다, 로더가 제공해야 할 결과를 골라 검사했습니다.

초안은 상세와 목록에서 각각 확인합니다

상세 페이지와 목록은 서로 다른 함수를 사용합니다.

따라서 초안 정책도 두 경로에서 확인해야 합니다.

getPost("draft")
→ null

getPosts()
→ 초안이 없는 목록

목록에서 초안을 제외한다고 상세 접근까지 자동으로 막히는 것은 아닙니다.

현재 테스트에서는 상세 조회의 null 반환과 목록 필터링을 각각 확인했습니다.

목록 테스트에는 .md라는 이름이 붙은 디렉터리도 넣었습니다.

await Deno.mkdir(
  `${dir}/nested.md`,
);

파일 이름의 확장자만 확인하면 이 디렉터리를 글로 처리하려 할 수 있습니다. 현재 코드의 entry.isFile 조건까지 함께 검증하기 위한 데이터입니다.

읽기 권한 오류는 실제 권한 제한으로 재현합니다

권한 오류 테스트에서는 Deno.readTextFile()을 가짜 함수로 바꾸지 않았습니다.

해당 테스트의 읽기 권한을 차단했습니다.

permissions: {
  read: false,
  write: ["./.test-tmp"],
}

그리고 실제 로더가 Deno.errors.NotCapable을 전달하는지 확인합니다.

await assertRejects(
  () => getPost("hello", dir),
  Deno.errors.NotCapable,
);

처음에는 다음 설정만 사용했습니다.

permissions: {
  read: false,
}

그런데 콘텐츠를 읽기 전에 테스트 파일을 준비하는 단계에서 실패했습니다.

Requires write access to "./.test-tmp"

테스트에 권한 객체를 지정하면서, 파일 생성과 정리에 필요한 쓰기 권한도 명시해야 했습니다.

수정한 테스트는 쓰기를 테스트 디렉터리에만 허용하고, 읽기는 차단합니다.

Deno의 테스트별 권한 설정은 실행 명령에서 허용한 범위 안에서 사용해야 합니다. 테스트 설정으로 실행 명령보다 더 넓은 권한을 얻을 수는 없습니다. Deno 테스트 권한 문서

이 방식으로 10편의 권한 오류가 다시 null로 숨겨지지 않는지 확인할 수 있습니다.

정렬 함수와 콘텐츠 로더의 연결도 검사합니다

프로젝트에는 이미 정렬 함수 테스트가 있습니다.

하지만 정렬 함수 자체가 올바른 것과, 파일에서 읽은 메타데이터를 그 함수에 전달하는 것은 별도 문제입니다.

이번 테스트는 실제 파일 네 개를 읽습니다.

글 날짜 order
first 2026-11-04 1
second 2026-11-04 2
third 2026-11-05 3
standalone 2026-11-06 없음

최신순 목록에서는 다음 결과를 기대합니다.

standalone
third
second
first

연재 목차에서는 번호가 없는 글을 제외하고 다음 순서가 됩니다.

first
second
third

이 테스트는 정렬 알고리즘을 다시 구현하지 않습니다. 파일의 날짜와 연재 번호가 getPosts(), getSeriesPosts()를 거쳐 올바른 결과에 연결되는지 확인합니다.

프로젝트 루트에서 실행합니다

이번 테스트의 파일 읽기와 쓰기는 .test-tmp 아래에서만 수행합니다.

프로젝트 루트에서 실행합니다.

deno test \
  --no-prompt \
  --allow-read=./.test-tmp \
  --allow-write=./.test-tmp \
  --deny-net \
  utils/posts_test.ts

의존성이 준비된 환경에서는 다운로드를 막고 반복할 수 있습니다.

deno test \
  --cached-only \
  --no-prompt \
  --allow-read=./.test-tmp \
  --allow-write=./.test-tmp \
  --deny-net \
  utils/posts_test.ts \
  utils/post-order_test.ts

실험에서는 콘텐츠 로더 테스트 8개와 기존 정렬 테스트 3개를 함께 실행했습니다.

ok | 11 passed | 0 failed

변경한 로더와 테스트 파일의 린트도 통과했습니다.

여기서 통과한 것은 해당 파일과 테스트 범위입니다. 프로젝트 전체의 기존 포맷·타입 검사 문제까지 해결했다는 뜻은 아닙니다.

브라우저나 빌드된 서버는 실행하지 않았으며, 광고 스크립트를 요청하거나 실행하지 않았습니다.

오류를 숨기는 코드로 바꾸면 테스트가 실패합니다

테스트가 실제 회귀를 발견하는지도 확인했습니다.

임시 복사본에서 getPost()의 catch 범위를 다시 넓혀, 파싱과 렌더링까지 감싼 뒤 모든 오류에 null을 반환하도록 바꿨습니다.

try {
  // 파일 읽기
  // Front Matter 파싱
  // Markdown 렌더링
} catch {
  return null;
}

이 상태에서는 콘텐츠 로더 테스트 두 개가 실패했습니다.

깨진 YAML은 상세와 목록에서 예외로 전달된다
→ 실패

getPost: 읽기 권한 오류를 null로 숨기지 않는다
→ 실패

실패 메시지는 다음과 같았습니다.

Expected function to reject.

예외가 전달되어야 하는데 함수가 정상적으로 null을 반환했기 때문입니다.

오류를 구분하는 코드로 복원한 뒤에는 전체 11개 테스트가 다시 통과했습니다.

12편에서 수정한 오류 처리 정책을, 이후 변경에서도 유지할 수 있게 된 것입니다.

콘텐츠 검증과 로더 테스트를 함께 유지합니다

이번 테스트에는 잘못된 tags 타입이나 존재하지 않는 날짜를 검사하는 코드를 추가하지 않았습니다.

그것은 11편의 메타데이터 검증이 담당하는 규칙입니다. 현재 로더의 extractYaml<PostMeta>() 타입 지정만으로 필드 검증이 수행되지는 않습니다.

각 검사는 다른 실패를 찾습니다.

메타데이터 검증
→ 작성한 파일의 규칙 위반

콘텐츠 로더 테스트
→ 파일 처리 코드의 동작 변경

HTTP 테스트
→ 라우트와 응답의 연결 문제

콘텐츠 로더를 수정할 때는 임시 파일 테스트로 결과와 예외를 먼저 확인하고, 페이지에 영향을 주는 변경은 HTTP 테스트로 이어서 확인할 수 있습니다.

실제 글을 잘못된 상태로 만들지 않고도 초안, 파싱 실패, 권한 오류를 반복해서 재현할 수 있다는 점이 이번 구성의 가장 실용적인 부분입니다.

#Deno#Markdown#Testing#FileSystem#Fresh