📜

生きたドキュメントを書こう

2022/12/18に公開

この記事は Clojure Advent Calendar 2022 の12月07日目の穴埋めに向けた記事です。

TL;DR

  • サンプルコードは簡単に陳腐化するので、サンプルコードもテストするといいよ
  • Clojure 向けには拙作の liquidz/testdoc があるので使ってみてね

はじめに

皆さんはドキュメント書いているでしょうか?

Clojure ライブラリにおけるドキュメントでは cljdoc や最近では quickdoc などがあります。

https://cljdoc.org
https://github.com/borkdude/quickdoc

いずれも README や定義された関数の docstring などからドキュメントを自動生成するものです。
よってこの記事でも README もしくは関数の docstring を主軸において話を進めたいと思います。

ではドキュメントを用意するとして、どのようなドキュメントがあると有用でしょうか?
ライブラリとしては何を問題として認識していて、その問題をどのように解決しているのかの説明が一番大切かと思いますが、次に大切と考えているのがライブラリの 使い方 です。

どんなに有用なライブラリでも使い方がわからなければ使ってもらいづらいので、大抵のライブラリの README には使い方の欄がありますし、関数の docstring としてどんな引数を必要としているか/どんな戻り値が返るかをまとめているものも多いです。

特に後者の docstring は開発時に気軽に参照できるだけ、まとまっていると何かとありがたいものです。

(defn"何かすごい関数
  無を受け取り無を返す"
  []
  (do :無))

そこで以下のようにドキュメントをまとめたとしましょう。

  • README
    • ライブラリが解決したい問題、そしてライブラリによる解決方法
    • ライブラリの使い方
  • 関数の docstring
    • 各関数の引数として必要なもの
    • 各関数の戻り値として返されるもの

素晴らしいドキュメントが用意できました!これで使ってもらいやすくなりましたね!

それから1年

あなたが作ったライブラリはこの1年間で利用者も増え、機能も増えました。
提供している関数の引数/戻り値も1年前から多少なりとも変更があります。

さてREADMEならびにdocstringはその変更に追従していますか?
そのドキュメントは生きていますか?

追従できなかった方は勿論、できた方でも今後の開発/運用でずっと忘れずに更新できる保証はありません。
コードであればテスト駆動なりCIなりを通して修正漏れには気付けるでしょうが、ドキュメントは大抵後回しにされ、そして忘れられます。

これではライブラリを新しく利用したい方がいてもうまく利用できなくなってしまいます。
ではどうしたら良いでしょうか?

ドキュメントをテストする

ドキュメントをテストする仕組みはあります。
例えば Python の doctest がそれです。
https://docs.python.org/3/library/doctest.html

doctest ではモジュールや関数に紐づく docstring に含まれるコードとその結果が正しいかどうかをテストしてくれます。
これによりどういった引数を渡すとどういった結果が返ってくるのかを明示でき、かつ実装が変わればテストも通らなくなりドキュメントの修正漏れにも気付くことができるようになります。

Clojure 向けには drojas/doctest, Kobold/clj-doc-test などがありますが、いずれも長らくメンテナンスされておらず自分のユースケースにうまくフィットしなかったため自作したのが liquidz/testdoc です。

testdoc

https://github.com/liquidz/testdoc

例に習って README にも使い方は書いてありますが簡単に使い方を説明します。
まずはテスト対象となる関数を用意します。

(defn myplus
  "足し算

  (myplus 1 2)
  ;; => 3
  (myplus 2
          3)
  ;; => 5"
  [a b]
  (+ a b))
;; => var?

testdoc では2種類の記法を用意していますが、この記事では主に後者のスタイルで説明します。

  • REPL 上での入出力を模した REPL スタイル
    => (+ 1 2)
    3
    
  • 出力をコメントとして示すコードファーストスタイル
    (+ 1 2)
    ;; => 3
    

docstringのテスト

では実際に関数に定義した docstring を対象としたテストを実行してみましょう。
testdocclojure.test/is マクロを拡張して、(is (testdoc ...)) という書き方をできるようにしています。
よって clojure.test/deftest 内で他のテストコード同様に clojure.test/is を使ったテストコードを書くのみです。

(require '[clojure.test :as t]
         '[testdoc.core])
;; => nil

(t/deftest myplus-test
  (t/is (testdoc #'myplus)))
;; => var?

(t/test-var *1)
;; => nil

(is (testdoc)) では clojure.lang.Var もしくは文字列を受け取れます。
Var が渡された場合、メタデータ上の docstring からテスト対象のコードを抜き出し、その Var が定義されている namespace 上と同等の環境でテストが実行されます。
文字列が渡された場合はその文字列からテスト対象のコードを抜き出してテストが実行されます。こちらの場合 namespace は意識されないので必要な関数や別 namespace の require などは明示的に行う必要があります。

READMEのテスト

次は README 上のサンプルコードのテストも実行してみましょう。
先に説明した通り (is (testdoc)) では文字列をそのまま渡せるようにしてあるので、README を文字列として渡してあげればいいだけです。

(t/deftest README-test
  (t/is (testdoc (slurp "README.md"))))
;; => var?

(t/test-var *1)
;; => nil

ちなみに README をテストするライブラリとしては seancorfield/readme も存在します。

https://github.com/seancorfield/readme

こちらだとわざわざ clojure.test/deftest を使ったテストコードを書く必要がなく簡単にテスト実行できるので楽です。
しかし testdoc ではあえてこの方式は採用していません。

例えば外部サーバーへの通信といった副作用を持つ関数のテストをしたいとしましょう。

(defn- slurp-example
  "example.com から情報取得する

  (slurp-example)
  ;; => \"dummy contents\""
  []
  (slurp "https://example.com"))
;; => var?

これをそのままテストしようとすると実際に example.com への通信が行われてしまい、example.com の状況によっては通らない壊れやすいテストになってしまいます。
そのため外部サーバーとの通信はモック化したいですが、ここで clojure.core/deftest を使う testdoc の場合は話が簡単になります。
単に clojure.core/with-redefs などを使ってあげればいいだけなのです。

(t/deftest slurp-example-test
  (with-redefs [slurp-example (constantly "dummy contents")]
    (t/is (testdoc #'slurp-example))))
;; => var?

(t/test-var *1)
;; => nil

seancorfield/readme の方が easy ではありますが、カバーできないテストも出てきてしまうので testdoc では現在の仕様にしています。

最後に?

いかがだったでしょうか?
ライブラリに限らずドキュメントにサンプルコードを載せることはありがちなので、こういった仕組みで 生きた ドキュメントを維持できるようにすると良いですよという紹介でした。
testdoc のドキュメントも testdoc 自体でテストしていたりするので、興味があればご参照ください。

https://github.com/liquidz/testdoc

...

さてここでお気付きの方もいるかもしれませんが、この記事にもいくつかのサンプルコードを載せました。
ドキュメントに限らず記事もライブラリの更新によって陳腐化してしまいますし、この記事もその例に漏れません。

しかし安心してください!この記事も testdoc でテストしてます!

zenn.dev の記事のテスト

私は zenn.dev の記事を GitHub 連携しています。
プライベートリポジトリなのでコードへのリンクは貼れないのですが、以下のテストコードを push をトリガーとして GitHub Actions で動かしています。

(ns article-test
  (:require
    [clojure.java.io :as io]
    [clojure.string :as str]
    [clojure.test :as t]
    [testdoc.core]))

(def ^:private articles
  (->> (file-seq (io/file "articles"))
       (filter #(str/ends-with? (.getName %) ".md"))))

(t/deftest articles-test
  (doseq [article articles]
    (t/testing (.getName article)
      (t/is (testdoc (slurp article))))))

これにより記事内にある Clojure のコードで testdoc の記法に従ったものはすべてテストされます。

本当の最後に

ライブラリのドキュメントの話から脱線して記事内のサンプルコードにまで話を広げてしまいました。

しかし前述の通りサンプルコードを載せる機会は少なくないので、ライブラリ作者だけでなくドキュメントを書く方はこういった仕組みを導入することで初めて読む人だけでなく未来の自分にも優しくなれるかと思います。

Discussion