如何在Haskell中使用Doctests

184 阅读5分钟

编写软件的文档可能很有挑战性,但也不一定要这样。

在这篇文章中,我们将介绍doctests:一个使文档过程变得愉快和有效的概念。

通过将测试放在模块文档中,doctests允许功能自己说话,并帮助你从你的测试工作中获得最大的收益。

阅读这篇文章来了解。

  • 什么是doctests?
  • 如何在Haskell中定义它们。
  • 使用哪个库来进行Haskell测试。

什么是doctests?

Doctests是简单的嵌入在文档中的文本片段,看起来像交互式会话。 通过一个特殊的库,你可以运行这些会话并验证它们是否返回正确的值。

这个想法来自于Python的令人敬畏的doctest模块,但从那时起,它已经扩散到几乎所有的编程语言。

在Haskell中,doctests是Haddock注释中的GHCi会话。

如果你曾经不得不略过Haskell的源代码文档,你可能已经注意到那些在开头有花哨的>>> 符号的行。

-- | @const x@ is a unary function which evaluates to @x@ for all inputs.
--
-- >>> const 42 "hello"
-- 42
--
-- >>> map (const 42) [0..3]
-- [42,42,42,42]

正如你可能已经猜到的,那些是测试。

如何在Haskell中定义doctest?

让我们来看看一个基本测试的例子。

-- | 1 + 2 is 3.                          
-- >>> 1 + 2                 
-- 3

正如你所看到的,Haskell doctests有三个要求。

  • 每一个doctest的例子都应该放在有效的Haddock文档中,其标志是-- |{- |

  • 每个doctest例子都应该以>>> 开始,并包含一个有效的Haskell表达式,该表达式在范围内(有时你必须明确import 语句 - 我们将在文章中进一步介绍)。

  • 每个doctest例子后面都应该有一行,包含评估该表达式的预期结果。

你可以在doctest 库的readme中找到关于doctest标记的额外信息。

现在,让我们创建一个带有doctests的项目,并看看你可以用来运行这些doctests的Haskell库。

创建一个带有doctests的Haskell项目

为了使用测试库,我们需要创建一个带有doctests的Haskell项目。

首先,用以下方法启动一个项目 stack:

stack new doctests-demo

之后,进入项目的根目录,创建一个Haskell模块

cd doctests-demo
touch src/Sample.hs

最后,在新创建的Sample.hs 模块中添加一些带有doctests的函数。

-- src/Sample.hs

module Sample where

-- |
-- >>> foo + 13
-- 55
foo :: Integer
foo = 42

-- |
-- >>> bar
-- "bar"
bar :: String
bar = "bar"

该项目现在已经准备好运行doctests了。

Haskell中的Doctest库

我们将介绍Haskell生态系统中的两个doctest库。 doctestcabal-docspec.第一个比较老,也比较流行,第二个不太流行,但解决了doctest'的一些问题。

doctest

doctest语法库是最常用的、维护最积极的语法库之一,同时,它也有一些缺点,比如对大规模项目来说性能不好,以及对GHC这个库的依赖性。

如何使用doctest

首先,通过stack 安装该库。

stack install doctest

之后,进入项目的根目录,使用该库的可执行程序。

cd doctests-demo
doctest src

上面的命令应该输出这样的结果。

Examples: 2  Tried: 2  Errors: 0  Failures: 0

使用的缺点doctest

虽然它是一个很棒的软件,但该库也有一些缺点。

最大的缺点是该库对于大型项目来说似乎太慢了。 导致性能问题的原因之一是该库在每一组doctest例子之间都会重新加载源代码。 这样做是为了避免例子组之间相互影响。 你可以在该库的readme中了解更多这方面的信息。

还有几个比较小的缺点。

首先,它依赖于GHC这个库,这意味着当你把项目切换到一个新的编译器版本时,很可能最终会出现问题。

其次,当通过 stack时,你可能需要创建一个额外的测试套件来将项目的依赖性纳入范围。

依赖性缺点的说明。

让我们通过稍微编辑一下我们的doctest例子来说明这个问题(别忘了把aesontext 加入到依赖关系列表中)。

-- src/Sample.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell   #-}

module Sample where

import Data.Aeson
import Data.Aeson.TH
import Data.Text

data Anime =
    Anime { title  :: Text
          , rating :: Double
          }

$(deriveJSON defaultOptions ''Anime)

-- |
-- >>> encode favourite
-- "{\"title\":\"One-Punch Man\",\"rating\":8.9}"
--
favourite :: Anime
favourite = Anime "One-Punch Man" 8.9

现在,如果我们试图使用doctest src 来运行这些测试,我们会失败,出现以下信息。

src/Sample.hs:9:1: error:
    Could not find module ‘Data.Aeson’
    Perhaps you meant Data.Version (from base-4.14.3.0)
    Use -v (or `:set -v` in ghci) to see a list of the files searched for.
    |
9 | import Data.Aeson
    | ^^^^^^^^^^^^^^^^^

解决办法是创建一个额外的测试套件。

tests:

    doctests:
    source-dirs: doctests
    main: Main.hs
    ghc-options:
        - -threaded
        - -rtsopts
        - -with-rtsopts=-N
    dependencies:
        - doctest
        # bring in your project into the scope
        # as well as its dependencies
        - doctests-demo

之后,你需要添加测试套件目录和Main.hs 可执行文件。

mkdir doctests
touch doctests/Main.hs
-- doctests/Main.hs

import           Test.DocTest

-- This test suite exists only to add dependencies
main :: IO ()
main = doctest ["src"]

最后,使用以下命令运行测试。

stack test :doctests

cabal-docspec

cabal-docspec在这个库中,贡献者较少,社区的整体关注度也较低,但已经有很多人在实际项目中使用它。

如何使用cabal-docspec

让我们使用cabal-docspec 来运行我们的doctests-demo 项目中的 doctests。

首先,设置cabal-install 和全局编译器。我们建议使用ghcup来实现这一点。

之后,用Cabal构建项目。

cabal v2-build

然后从发布页面下载cabal-docspec 二进制文件。

curl -sL https://github.com/phadej/cabal-extras/releases/download/cabal-docspec-0.0.0.20211114/cabal-docspec-0.0.0.20211114.xz > cabal-docspec.xz
xz -d < cabal-docspec.xz > "$HOME"/.local/bin/cabal-docspec
rm -f cabal-docspec.xz
chmod a+x "$HOME"/.local/bin/cabal-docspec

现在,运行doctest的例子。

cabal-docspec

上面的命令应该失败,出现以下错误。

expected: "{\"title\":\"One-Punch Man\",\"rating\":8.9}"
but got:
            ^
            <interactive>:10:1: error:
                Variable not in scope: encode :: Anime -> t

出现上述情况是因为库的处理方式有些不同--它要求模块被明确地导入/导出。

让我们编辑一下我们的doctests,使cabal-docspec 工作。

 -- |
+ -- >>> import Data.Aeson
 -- >>> encode favourite
 -- "{\"title\":\"One-Punch Man\",\"rating\":8.9}"
 --
 favourite :: Anime
 favourite = Anime "One-Punch Man" 8.9

现在,cabal-docspec 命令应该成功了。

Total:         2; Tried:    2; Skipped:    0; Success:    2; Errors:    0; Failures    0
Examples:      2; Tried:    2; Skipped:    0; Success:    2; Errors:    0; Failures    0

为什么你应该使用cabal-docspec

以下是你可能想为你的项目使用cabal-docspec 的原因。

  • 这个库比其他的库快很多,因为它使用的是编译过的代码。

  • 该库不依赖于GHC这个库,所以它对GHC的版本变化有更强的适应性。

  • 如果你只改变测试,该库不需要重新编译源代码--这为开发者节省了额外的时间,并使编写文档变得愉快。

所有这些都使它成为一个可行的替代方案。

然而,请注意,该库似乎与 cabal-install因为它使用cabal-install 生成的元数据--plan.json 文件。

结论

谢谢你的阅读!

在这篇文章中,我们了解了doctests的概念,学习了如何在Haskell中定义它们,并简要介绍了两个可以帮助我们验证它们的库:doctestcabal-docspec

在本系列的下一部分,我们将更详细地介绍cabal-docspec 的配置和内部情况。要保持更新,请在Twitter上关注我们,或通过下面的表格订阅通讯。