Fresh Notes
Fresh 블로그에 HTTP 테스트 붙이기: 빌드 성공 뒤에 확인할 것들 | Fresh Notes

Fresh

Fresh 블로그에 HTTP 테스트 붙이기: 빌드 성공 뒤에 확인할 것들

Deno.test로 빌드된 Fresh 서버의 상태 코드, HTML, 검색 결과, 사이트맵과 CSS 응답을 검사합니다. 브라우저나 외부 광고 요청 없이 반복 실행할 수 있는 HTTP 응답 테스트를 만듭니다.

2026.10.22·Fresh·7 min read

빌드 성공과 페이지 정상 동작은 따로 확인합니다

Fresh 프로젝트의 빌드가 성공하면 서버와 클라이언트의 실행 파일이 만들어집니다.

하지만 빌드 성공만으로 다음 내용까지 확인되지는 않습니다.

9편에서 정리한 배포 전 확인을 매번 수동으로 반복하면 빠뜨리는 항목이 생기기 쉽습니다.

이번에는 확인할 조건을 테스트 파일로 남기겠습니다.

12편의 오류 페이지를 적용한 상태를 기준으로, 빌드된 서버가 반환하는 HTTP 상태·헤더·본문을 검사합니다.

실험에서는 Fresh 2.3.3, @fresh/plugin-vite 1.1.2, Deno 2.8.3을 사용했습니다. 실제 프로젝트의 임시 복사본에서 코드를 작성하고 실행했으며, 운영 프로젝트 파일은 수정하지 않았습니다.

브라우저 대신 서버 핸들러를 호출합니다

Fresh 공식 문서는 App으로 테스트용 앱을 구성하고, 핸들러에 Request를 전달하는 방식을 설명합니다. 개별 핸들러나 미들웨어를 확인할 때 사용할 수 있습니다. Fresh 테스트 문서

이번에는 프로젝트 전체의 빌드 결과를 확인하므로, 생성된 서버 진입점을 불러옵니다.

프로젝트 코드
→ Vite 빌드
→ _fresh/server.js
→ Request 전달
→ Response 검사

핵심 호출은 다음과 같습니다.

const response = await server.fetch(
  new Request("http://localhost:8000/search?q=Markdown"),
);

여기서 server.fetch()는 불러온 서버 객체의 함수입니다.

전역 fetch()로 네트워크 요청을 보내는 코드가 아닙니다. 포트를 열거나 별도 서버 프로세스를 시작하지 않고, 요청 객체를 처리하는 함수를 직접 호출합니다.

반환된 HTML은 문자열로 읽습니다.

const html = await response.text();

이 과정에서는 HTML 안의 <script>를 실행하지 않습니다. 이미지나 CSS도 브라우저처럼 자동으로 요청하지 않습니다.

따라서 광고 스크립트가 HTML에 있더라도, 이 테스트가 해당 주소로 광고 코드를 다운로드하는 것은 아닙니다. CSS 검사는 뒤에서 필요한 경로를 직접 선택해 서버 핸들러에 전달합니다.

확인할 동작을 아홉 개로 정합니다

테스트 대상은 현재 블로그에서 중요한 요청으로 좁혔습니다.

대상 확인할 내용
홈 200, HTML, 글 링크
글 상세 200, 본문, canonical
검색 결과 있음 200, 예상 글 링크
검색 결과 없음 200, 결과 없음 안내
없는 글 404, HTML 안내, 검색 링크
없는 라우트 404, HTML 안내, 검색 링크
사이트맵 200, XML 응답 형식, 공개 글 URL
robots.txt 200, 텍스트 응답 형식, 사이트맵 위치
빌드된 CSS HTML에서 경로 추출, 200, CSS 응답 형식

404를 두 번 검사하는 이유가 있습니다.

/posts/없는-글
→ 상세 페이지 라우트는 존재
→ 글 데이터가 없음

/no/such/route
→ 매칭되는 페이지 라우트가 없음

서로 다른 경로로 오류 처리에 도달하므로 둘 다 확인합니다.

검색 결과가 없는 상황은 404로 기대하지 않습니다. 검색 요청을 정상적으로 처리한 결과가 빈 목록인 것이므로 200과 안내 문구를 확인합니다.

실행할 수 있는 테스트 파일을 만듭니다

프로젝트 루트에 tests/http_test.ts를 만듭니다.

아래는 전체 코드입니다.

import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { SITE } from "../config.ts";

const serverUrl = new URL(
  "../_fresh/server.js",
  import.meta.url,
);

const { default: server } = await import(
  serverUrl.href
) as {
  default: {
    fetch(
      request: Request,
    ): Response | Promise<Response>;
  };
};

assert(
  typeof server.fetch === "function",
  "server.fetch가 없습니다",
);

const ORIGIN = "http://localhost:8000";
const POST_PATH = "/posts/fresh-markdown-blog";

async function request(path: string) {
  const response = await server.fetch(
    new Request(new URL(path, ORIGIN)),
  );

  const body = await response.text();

  return { response, body };
}

function assertType(
  response: Response,
  expected: string,
) {
  const actual = response.headers
    .get("content-type")
    ?.split(";")[0]
    .trim();

  assertEquals(actual, expected);
}

Deno.test(
  "home: HTML과 글 링크를 반환한다",
  async () => {
    const { response, body } = await request("/");

    assertEquals(response.status, 200);
    assertType(response, "text/html");

    assert(
      /href="\/posts\/[a-z0-9-]+"/.test(body),
    );
  },
);

Deno.test(
  "post: 본문과 canonical을 반환한다",
  async () => {
    const { response, body } = await request(
      POST_PATH,
    );

    assertEquals(response.status, 200);
    assertType(response, "text/html");

    assert(
      body.includes(
        "Fresh에서 Markdown 블로그 만들기",
      ),
    );

    const article = body.match(
      /<article\b[^>]*>([\s\S]*?)<\/article>/,
    )?.[1];

    assert(article, "글 본문이 없습니다");
    assert(article.includes("Front Matter"));

    const links = body.match(
      /<link\b[^>]*>/g,
    ) ?? [];

    const canonical = links.find(
      (tag) => /\brel="canonical"/.test(tag),
    );

    assert(
      canonical,
      "canonical link가 없습니다",
    );

    const href = canonical.match(
      /\bhref="([^"]+)"/,
    )?.[1];

    assertEquals(
      href,
      `${SITE.url}${POST_PATH}`,
    );
  },
);

Deno.test(
  "search: 서버가 검색 결과 링크를 렌더링한다",
  async () => {
    const { response, body } = await request(
      "/search?q=Markdown",
    );

    assertEquals(response.status, 200);

    assert(
      body.includes(`href="${POST_PATH}"`),
    );
  },
);

Deno.test(
  "search: 결과가 없어도 정상 응답이다",
  async () => {
    const { response, body } = await request(
      "/search?q=zz-no-match-83b0a29d",
    );

    assertEquals(response.status, 200);

    assert(
      body.includes("검색 결과가 없습니다."),
    );
  },
);

for (
  const path of [
    "/posts/does-not-exist-83b0a29d",
    "/no/such/route-83b0a29d",
  ]
) {
  Deno.test(`404: ${path}`, async () => {
    const { response, body } = await request(
      path,
    );

    assertEquals(response.status, 404);
    assertType(response, "text/html");

    assert(
      body.includes(
        "페이지를 찾을 수 없습니다",
      ),
    );

    assert(
      body.includes('href="/search"'),
    );
  });
}

Deno.test(
  "sitemap: XML과 공개 글 URL을 반환한다",
  async () => {
    const { response, body } = await request(
      "/sitemap.xml",
    );

    assertEquals(response.status, 200);
    assertType(response, "application/xml");

    assert(
      body.includes(
        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
      ),
    );

    assert(
      body.includes(
        `<loc>${SITE.url}${POST_PATH}</loc>`,
      ),
    );
  },
);

Deno.test(
  "robots: sitemap 위치를 안내한다",
  async () => {
    const { response, body } = await request(
      "/robots.txt",
    );

    assertEquals(response.status, 200);
    assertType(response, "text/plain");

    assert(
      body.includes(
        `Sitemap: ${SITE.url}/sitemap.xml`,
      ),
    );
  },
);

Deno.test(
  "assets: HTML이 참조한 빌드 CSS를 제공한다",
  async () => {
    const home = await request("/");

    assertEquals(
      home.response.status,
      200,
    );

    const links = home.body.match(
      /<link\b[^>]*>/g,
    ) ?? [];

    const stylesheet = links.find(
      (tag) => /\brel="stylesheet"/.test(tag),
    );

    assert(
      stylesheet,
      "stylesheet link가 없습니다",
    );

    const href = stylesheet.match(
      /\bhref="([^"]+)"/,
    )?.[1];

    assert(href, "CSS href가 없습니다");

    const cssUrl = new URL(
      href,
      ORIGIN,
    );

    assertEquals(
      cssUrl.origin,
      ORIGIN,
      "외부 CSS는 이 테스트 대상이 아닙니다",
    );

    const { response, body } = await request(
      cssUrl.pathname + cssUrl.search,
    );

    assertEquals(response.status, 200);
    assertType(response, "text/css");
    assert(body.length > 0);
  },
);

Deno.test()는 Deno에 포함된 테스트 API입니다. 조건 비교에는 표준 라이브러리의 assert, assertEquals를 사용했습니다. Deno 테스트 문서

서버 진입점은 런타임에 불러오고, 테스트에서 사용할 fetch()의 입력과 출력 형태를 선언했습니다.

이 타입 선언이 생성된 서버 코드를 검증하는 것은 아닙니다. 실제 함수가 있는지 확인하고, 호출 결과는 각 테스트에서 검사합니다. 원본 애플리케이션의 타입 검사도 별도로 유지해야 합니다.

응답 본문까지 읽는 이유

공통 함수는 Response만 반환하지 않습니다.

async function request(path: string) {
  const response = await server.fetch(
    new Request(new URL(path, ORIGIN)),
  );

  const body = await response.text();

  return { response, body };
}

HTTP 상태와 본문을 함께 검사하기 위해서입니다.

예를 들어 다음 응답은 상태 코드만 보면 정상입니다.

HTTP 200
Content-Type: text/html

요청을 처리하지 못했습니다

반대로 404 상태가 맞더라도, 우리가 만든 오류 화면 대신 짧은 텍스트만 반환할 수 있습니다.

그래서 404 테스트에서는 세 가지를 함께 확인합니다.

assertEquals(response.status, 404);
assertType(response, "text/html");

assert(
  body.includes(
    "페이지를 찾을 수 없습니다",
  ),
);

응답 본문을 끝까지 읽는 것은 스트림을 소비하는 역할도 합니다. 이 테스트에서는 각 요청의 응답을 읽고 검사를 마칩니다.

첫 실행에서 404 테스트 두 개가 실패했습니다

실험 당시 저장소에는 12편의 원고가 있었지만, 원고에서 설명한 오류 처리 코드는 아직 적용되지 않은 상태였습니다.

그 상태의 빌드 결과에 테스트를 실행하니 다음 결과가 나왔습니다.

FAILED | 7 passed | 2 failed

실패한 것은 없는 글과 없는 라우트였습니다.

두 요청 모두 상태 코드는 404였지만, 응답 형식이 예상과 달랐습니다.

Actual:   text/plain
Expected: text/html

상태 코드만 검사했다면 두 테스트는 통과했을 것입니다.

하지만 이번 테스트에는 공통 HTML 안내 화면을 제공한다는 조건도 들어 있습니다. 따라서 404라는 숫자가 맞아도 구현이 완료된 것으로 판단하지 않았습니다.

임시 복사본에 12편의 _error.tsx, 콘텐츠 로더, 상세 핸들러 변경을 적용하고 다시 빌드했습니다.

그다음 실행에서는 아홉 개 테스트가 모두 통과했습니다.

ok | 9 passed | 0 failed

이 원고의 404 테스트는 12편의 구현을 전제로 합니다. 아직 적용하지 않았다면 먼저 해당 코드를 반영해야 합니다.

홈에는 특정 오래된 글을 고정하지 않습니다

테스트를 작성하는 과정에서는 테스트의 기대값도 수정했습니다.

처음에는 홈에 다음 글의 링크가 있는지 확인했습니다.

/posts/fresh-markdown-blog

하지만 현재 홈은 최신 글 다섯 개와 대표 글을 보여줍니다. 글이 늘어나면 오래된 글이 홈에서 빠지는 것이 정상입니다.

그래서 홈 테스트는 특정 글 대신 글 링크가 존재하는지 확인하도록 바꿨습니다.

assert(
  /href="\/posts\/[a-z0-9-]+"/.test(body),
);

특정 글의 본문과 canonical은 상세 페이지 테스트에서 확인합니다.

테스트가 실패했을 때는 구현이 잘못되었는지, 테스트가 정상 동작을 잘못 가정했는지 구분해야 합니다. 홈의 모든 글을 고정해 두면 새 글을 발행하는 정상적인 작업까지 실패로 잡게 됩니다.

예제의 POST_PATH는 계속 공개 상태로 유지할 기존 글을 선택했습니다. 해당 글을 삭제하거나 주소를 바꾸면 테스트 대상도 함께 변경해야 합니다.

CSS 파일 이름은 HTML에서 찾습니다

Vite가 생성한 CSS 파일 이름에는 해시가 포함될 수 있습니다.

/assets/client-entry-CkWUhHBo.css

이 이름을 테스트에 고정하면 CSS가 바뀌어 새 파일이 만들어질 때마다 테스트도 수정해야 합니다.

이번에는 홈 HTML의 stylesheet 링크를 찾습니다.

const stylesheet = links.find(
  (tag) => /\brel="stylesheet"/.test(tag),
);

그리고 그 href를 서버 핸들러에 전달합니다.

홈 HTML 요청
→ stylesheet 링크 확인
→ href 추출
→ 같은 서버의 CSS 경로 요청
→ 상태·응답 형식·본문 확인

이렇게 하면 HTML에 CSS 경로가 적혀 있다는 것과, 그 파일을 실제로 제공할 수 있다는 것을 함께 확인합니다.

이를 확인하기 위해 별도 실행에서 _fresh/client 읽기 권한을 제외했습니다.

홈 HTML은 반환되었지만 CSS 요청은 500으로 실패했고, CSS 테스트가 이를 발견했습니다.

Actual:   500
Expected: 200

10편에서 확인한 파일 읽기 권한이 정적 자산 제공에도 필요하다는 점을 테스트로 다시 확인한 것입니다.

예제의 정규식은 현재 프로젝트가 생성하는 제한된 HTML 태그를 확인하기 위한 것입니다. 범용 HTML 파서를 구현한 코드는 아닙니다.

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

먼저 빌드합니다.

deno task build

이어서 테스트를 실행합니다.

deno test \
  --no-prompt \
  --allow-read=./content/posts,./_fresh \
  --deny-net \
  tests/http_test.ts

프로젝트 루트에서 실행하는 이유는 콘텐츠 로더가 다음 상대 경로를 사용하기 때문입니다.

const POSTS_DIR = "./content/posts";

읽기 권한도 테스트에 필요한 범위로 지정했습니다.

경로 용도
./content/posts 글 목록, 본문, 검색 데이터
./_fresh 생성된 서버 모듈과 빌드 자산

--no-prompt는 빠진 권한을 대화형으로 허용하지 않고 실패하게 합니다. --deny-net은 테스트 실행 중 애플리케이션 코드의 네트워크 접근을 금지합니다. Deno 테스트에도 일반 실행과 같은 권한 모델이 적용됩니다. Deno 테스트 권한 문서

다만 의존성 모듈을 준비하는 과정은 구분해야 합니다. 처음 실행할 때 @std/assert가 캐시에 없다면 Deno가 JSR에서 모듈을 받을 수 있습니다.

의존성까지 준비된 환경에서 다운로드 없이 반복하려면 다음처럼 실행합니다.

deno test \
  --cached-only \
  --no-prompt \
  --allow-read=./content/posts,./_fresh \
  --deny-net \
  tests/http_test.ts

이번에 아홉 개 테스트의 통과를 확인한 실행에도 --cached-only와 --deny-net을 사용했습니다.

브라우저를 열거나 광고 스크립트를 요청하지 않았습니다.

빌드와 테스트를 하나의 작업으로 묶습니다

소스 코드를 수정한 뒤 테스트만 실행하면 이전 빌드 결과를 검사할 수 있습니다.

라우트 코드 수정
→ 빌드 생략
→ 테스트 실행
→ 이전 서버 코드 검사

이를 줄이기 위해 deno.json의 기존 tasks에 다음 두 항목을 추가합니다.

{
  "tasks": {
    "test:http": "deno test --no-prompt --allow-read=./content/posts,./_fresh --deny-net tests/http_test.ts",
    "check:http": "deno task build && deno task test:http"
  }
}

기존 build, dev, check 같은 항목은 유지합니다.

이제 배포 전에는 다음 작업을 실행합니다.

deno task check:http

항상 새 빌드가 먼저 만들어지고, 빌드가 성공한 경우에만 HTTP 테스트가 실행됩니다.

기존 포맷·린트·타입 검사도 함께 유지합니다. 이 테스트가 통과했다는 결과는 선택한 요청의 동작을 확인했다는 뜻이며, 프로젝트의 모든 소스 검사를 통과했다는 뜻은 아닙니다.

전체 HTML을 저장해서 비교하지 않았습니다

이번 테스트는 HTML 전체를 스냅샷으로 저장하지 않습니다.

Fresh가 생성하는 내부 마커, CSS 클래스, 글 발행에 따른 목록 변경까지 모두 비교하면 작은 수정에도 테스트 결과가 크게 바뀔 수 있습니다.

대신 독자와 클라이언트에게 중요한 결과를 선택했습니다.

상태 코드가 맞는가?
예상한 응답 형식인가?
글 본문이 있는가?
검색 결과 링크가 있는가?
오류 안내와 이동 경로가 있는가?
HTML이 참조한 CSS를 제공할 수 있는가?

사이트맵 검사도 현재는 응답 형식과 필요한 URL이 포함되는지 확인하는 수준입니다. XML 전체의 문법이나 모든 URL의 유효성을 검증한 것은 아닙니다.

또한 서버 핸들러를 직접 호출하므로 운영 도메인의 DNS, HTTPS, 프록시나 CDN 설정은 이 테스트 범위에 포함되지 않습니다. 이 테스트의 통과가 Google에서 사이트맵을 가져올 수 있다는 보장으로 이어지지는 않습니다.

브라우저의 CSS 배치, Island hydration, 버튼 클릭 동작 역시 별도의 확인 대상입니다.

배포 전에 같은 질문을 다시 할 수 있게 만듭니다

12편에서는 없는 글과 서버 오류를 구분하는 코드를 작성했습니다.

이번에는 그중 배포 전에 반복해서 확인할 HTTP 동작을 테스트 파일로 남겼습니다.

그 결과 상태 코드가 맞아도 오류 화면이 빠진 경우를 발견했고, HTML이 정상이어도 CSS 제공이 실패하는 상황을 구분할 수 있었습니다.

이제 라우트나 콘텐츠 처리 코드를 수정했을 때 같은 주소를 하나씩 열어보는 대신, 빌드와 테스트를 실행해 확인할 수 있습니다. 실패한 테스트 이름과 실제 응답을 바탕으로 수정할 부분도 좁힐 수 있습니다.

#Fresh#Deno#Testing#HTTP#Vite