git.iko.soy/servant-quickcheck
on master help sign in
docs
65a0809Julian K. Arni
.gitignore+3
@@ -1,2 +1,5 @@
1doc/_build/
1doc/_build/
2scripts/
2scripts/
3+samples/
4+test-servers/
5+/doc/
doc/posts/Announcement.anansi+35−12
@@ -102,9 +102,8 @@ import Control.Monad.IO.Class (liftIO)
102type API
102type API
103 = "species" :> (Capture "species-name" Text :> ( Get '[JSON] Species
103 = "species" :> (Capture "species-name" Text :> ( Get '[JSON] Species
104 :<|> Delete '[JSON] ())
104 :<|> Delete '[JSON] ())
105− :<|> ReqBody '[JSON] Species :> Post '[JSON] ())
105+ :<|> ReqBody '[JSON] Species :> Post '[JSON] ()
106− -- The plural of 'species' is unfortunately also 'species'
107− :<|> "speciess" :> Get '[JSON] [Species]
106+ :<|> Get '[JSON] [Species])
108
107
109api :: Proxy API
108api :: Proxy API
110api = Proxy
109api = Proxy
@@ -165,6 +164,8 @@ If you want to run this example.)
165This is a plausible effort. You might want to spend a moment thinking about what
164This is a plausible effort. You might want to spend a moment thinking about what
166could be improved.
165could be improved.
167
166
167+Here are some `servant-quickcheck`-based tests for this API:
168+
168:d Spec.hs
169:d Spec.hs
169
170
170{-# LANGUAGE OverloadedStrings #-}
171{-# LANGUAGE OverloadedStrings #-}
@@ -184,8 +185,6 @@ spec = describe "the species application" $ beforeAll check $ do
184 return $ server conn
185 return $ server conn
185
186
186
187
187− it "should not return 500s" $ do
188−
189 it "should not return 500s" $ do
188 it "should not return 500s" $ do
190 withServantServer api pserver $ \url ->
189 withServantServer api pserver $ \url ->
191 serverSatisfies api url defaultArgs (not500 <%> mempty)
190 serverSatisfies api url defaultArgs (not500 <%> mempty)
@@ -194,11 +193,29 @@ spec = describe "the species application" $ beforeAll check $ do
194 withServantServer api pserver $ \url ->
193 withServantServer api pserver $ \url ->
195 serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
194 serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
196
195
197− where
196+ it "should return valid locations for 201" $ do
198− check = do
197+ withServantServer api pserver $ \url ->
199− mvar <- newMVar []
198+ serverSatisfies api url defaultArgs (createContainsValidLocation <%> mempty)
199+
200+ it "honours Accept header" $ do
201+ withServantServer api pserver $ \url ->
202+ serverSatisfies api url defaultArgs (honoursAcceptHeader <%> mempty)
203+
204+ it "405s contain Allow header" $ do
205+ withServantServer api pserver $ \url ->
206+ serverSatisfies api url defaultArgs (notAllowedContainsValidAllow <%> mempty)
207+
208+ it "should contain WWW-Authenticate header when returning 401s" $ do
209+ withServantServer api pserver $ \url ->
210+ serverSatisfies api url defaultArgs (unauthorizedContainsWWWAuthenticate <%> mempty)
211+
212+ it "GETs should have Cache-Control header" $ do
213+ withServantServer api pserver $ \url ->
214+ serverSatisfies api url defaultArgs (getsHaveCacheControlHeader <%> mempty)
215+
216+ it "HEADs should have Cache-Control header" $ do
200− withServantServer api pserver $ \url ->
217+ withServantServer api pserver $ \url ->
201− serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
218+ serverSatisfies api url defaultArgs (headsHaveCacheControlHeader <%> mempty)
202
219
203main :: IO ()
220main :: IO ()
204main = do
221main = do
@@ -208,10 +225,16 @@ instance Arbitrary Species where
208 arbitrary = Species <$> arbitrary <*> arbitrary
225 arbitrary = Species <$> arbitrary <*> arbitrary
209:
226:
210
227
211−But this fails in quite a few ways.
212
228
229+I won't go through all of the failures; as this is a literate haskell file, you
230+can go through them yourself if you're interested. But worth mentioning:
231+
232+ - The possible pattern match failure in `lookupSpecies` is caught.
233+ - Returning a top-level list is caught.
234+ - Not having a link for the `PostCreated` is caught.
235+
236+This last test failure illustrates an important point.
213
237
214−<<TODO>>
215
238
216This was an example created with the knowledge of what it was supposed to
239This was an example created with the knowledge of what it was supposed to
217exemplify. To try to get a more accurate assessment of the practical usefulness
240exemplify. To try to get a more accurate assessment of the practical usefulness
doc/posts/Makefile+6−3
@@ -4,12 +4,15 @@ src/$(FILES): Announcement.anansi
4 anansi tangle -o "src" Announcement.anansi
4 anansi tangle -o "src" Announcement.anansi
5
5
6announcement.md: Announcement.anansi
6announcement.md: Announcement.anansi
7− anansi weave -o "announcement.md" Announcement.anansi
7+ anansi weave -o "announcement.tmp" Announcement.anansi
8+ cat announcement.tmp | tr -d '«' | tr -d '»' > announcement.md
9+ rm announcement.tmp
8
10
9.stack-work/bin/posts: $(FILES) stack.yaml posts.cabal
11.stack-work/bin/posts: $(FILES) stack.yaml posts.cabal
10 stack build
12 stack build
11
13
12−
14+announcement.html: announcement.md
15+ pandoc announcement.md -t html > announcement.html
13
16
14run: .stack-work/bin/posts
17run: .stack-work/bin/posts
15 stack exec posts
18 stack exec posts
@@ -17,6 +20,6 @@ run: .stack-work/bin/posts
17test: .stack-work/bin/posts
20test: .stack-work/bin/posts
18 stack test
21 stack test
19
22
20−post: announcement.md
23+post: announcement.html
21
24
22.PHONY: post run test
25.PHONY: post run test
doc/posts/announcement.md+224−24
@@ -9,22 +9,71 @@ an API description.
9
9
10This is much closer to the traditional use of `QuickCheck`. The most obvious
10This is much closer to the traditional use of `QuickCheck`. The most obvious
11use-case is checking that properties hold of an *entire* server rather than of
11use-case is checking that properties hold of an *entire* server rather than of
12−individual endpoints.
12+individual endpoints. (But there are other uses that you can skip to if they
13+sound more interesting.)
13
14
14## `serverSatisfies`
15## `serverSatisfies`
15
16
16−There are a variety of best practices in writing web APIs that aren't always
17+A useful guideline when writing and maintaing software is that, if there isn't
17−obvious. As a running example, let's use a simple service that allows adding,
18+a test for a behaviour or property, sooner or later that property will be broken.
18−removing, and querying biological species. Our SQL schema is:
19+Another important perspective is that tests are a form of documentation - the
20+present developer telling future ones "this matters, and should be this way".
19
21
22+The advantage of using tests for this form of documentation is that there's
23+simply too much information to convey, some of it only relevant to very specific
24+use cases, and rather than overload developers with an inexhaustible quantity of
25+details that would be hard to keep track of or remember, tests are a mechanism
26+of reminding developers of *only the relevant information, at the right time*.
27+<<EXAMPLE>>.
20
28
29+We might hope that we could use tests to communicate the wide array of best
30+practices that have developed around APIs. About to return a top-level integer
31+in JSON? A test should say that's bad practice. About to not catch exceptions
32+and give a more meaningful HTTP status code? Another test there to stop you.
33+
34+Traditionally, in web services these things get done at the level of *individual*
35+endpoints. But this means that if a developer who hasn't had extensive experience with web
36+programming best practices writes a *new* endpoint which *does* return a top-level
37+integer literal, there's no test there to stop her. Code review might help, but
38+code review is much more error prone than tests, and really only meant for those
39+things that are too subtle to automate. (Indeed, if code review were such a reliable
40+defense mechanism against bugs and bad code, why have tests and linters at all?)
41+
42+The problem, then, with thinking about tests as only existing at the level of individual
43+endpoints is that there are no tests *for* tests - tests that check that new
44+behaviour and tests conforms to higher-level, more general best practices.
45+
46+`servant-quickcheck` aims to solve that. It allows describing properties that
47+*all* endpoints must satisfy. If a new endpoint comes along, it too will be
48+tested for that property, without any further work.
49+
50+Why isn't this idea already popular? Well, most web frameworks don't have a
51+reified description of APIs (beyond perhaps the routes). When you don't know
52+what the endpoints of an application are, and what request body they expect,
53+trying to generate arbitrary requests is almost entirely going to result in
54+404s (not found) and 400s (bad request). Maybe one in a thousand requests will
55+actually test a handler. Not very useful.
56+
57+`servant` applications, on the other hand, have a machine-readable API description
58+already available. And they already associate "correct" requests with particular
59+types. It's a small step, therefore, to generate 'arbitrary' values for these
60+requests, and all of them will go through to your handlers. (Note: all of the
61+uses of `servant-quickcheck` work with applications *not* written with servant-server -
62+and indeed not *in Haskell - but the API must be described with the servant
63+DSL.)
64+
65+Let's see how this works in practice. As a running example, let's use a simple
66+service that allows adding, removing, and querying biological species. Our SQL
67+schema is:
68+
69+
21−> **«schema.sql»**
70+> **schema.sql**
22
71
23>
72>
24> CREATE TABLE genus (
73> CREATE TABLE genus (
25> genus_name text PRIMARY KEY,
74> genus_name text PRIMARY KEY,
26> genus_family text NOT NULL
75> genus_family text NOT NULL
27−> )
76+> );
28>
77>
29> CREATE TABLE species (
78> CREATE TABLE species (
30> species_name text PRIMARY KEY,
79> species_name text PRIMARY KEY,
@@ -35,7 +84,7 @@ removing, and querying biological species. Our SQL schema is:
35And our actual application:
84And our actual application:
36
85
37
86
38−> **«Main.hs»**
87+> **Main.hs**
39
88
40> {-# LANGUAGE DataKinds #-}
89> {-# LANGUAGE DataKinds #-}
41> {-# LANGUAGE DeriveAnyClass #-}
90> {-# LANGUAGE DeriveAnyClass #-}
@@ -43,6 +92,8 @@ And our actual application:
43> {-# LANGUAGE TypeOperators #-}
92> {-# LANGUAGE TypeOperators #-}
44> {-# LANGUAGE OverloadedStrings #-}
93> {-# LANGUAGE OverloadedStrings #-}
45> {-# LANGUAGE RecordWildCards #-}
94> {-# LANGUAGE RecordWildCards #-}
95+> module Main where
96+>
46> import Servant
97> import Servant
47> import Data.Aeson
98> import Data.Aeson
48> import Database.PostgreSQL.Simple
99> import Database.PostgreSQL.Simple
@@ -54,8 +105,9 @@ And our actual application:
54> type API
105> type API
55> = "species" :> (Capture "species-name" Text :> ( Get '[JSON] Species
106> = "species" :> (Capture "species-name" Text :> ( Get '[JSON] Species
56> :<|> Delete '[JSON] ())
107> :<|> Delete '[JSON] ())
57−> :<|> ReqBody '[JSON] Species :> Post '[JSON] ()
108+> :<|> ReqBody '[JSON] Species :> Post '[JSON] ())
58−> :<|> "count" :> Get '[JSON] Int)
109+> -- The plural of 'species' is unfortunately also 'species'
110+> :<|> "speciess" :> Get '[JSON] [Species]
59>
111>
60> api :: Proxy API
112> api :: Proxy API
61> api = Proxy
113> api = Proxy
@@ -74,19 +126,19 @@ And our actual application:
74> instance FromRow Species
126> instance FromRow Species
75>
127>
76> server :: Connection -> Server API
128> server :: Connection -> Server API
77−> server conn = (\sname -> liftIO (lookupSpecies conn sname)
129+> server conn = ((\sname -> liftIO (lookupSpecies conn sname)
78−> :<|> liftIO (deleteSpecies conn sname))
130+> :<|> liftIO (deleteSpecies conn sname))
79−> :<|> (\species -> liftIO $ insertSpecies conn species)
131+> :<|> (\species -> liftIO $ insertSpecies conn species))
80−> :<|> (liftIO $ countSpecies conn)
132+> :<|> (liftIO $ allSpecies conn)
81>
133>
82> lookupSpecies :: Connection -> Text -> IO Species
134> lookupSpecies :: Connection -> Text -> IO Species
83> lookupSpecies conn name = do
135> lookupSpecies conn name = do
84−> [s] <- query conn "SELECT * FROM species WHERE species_name == ?" (Only name)
136+> [s] <- query conn "SELECT * FROM species WHERE species_name = ?" (Only name)
85> return s
137> return s
86>
138>
87> deleteSpecies :: Connection -> Text -> IO ()
139> deleteSpecies :: Connection -> Text -> IO ()
88> deleteSpecies conn name = do
140> deleteSpecies conn name = do
89−> _ <- execute conn "DELETE FROM species WHERE species_name == ?" (Only name)
141+> _ <- execute conn "DELETE FROM species WHERE species_name = ?" (Only name)
90> return ()
142> return ()
91>
143>
92> insertSpecies :: Connection -> Species -> IO ()
144> insertSpecies :: Connection -> Species -> IO ()
@@ -94,25 +146,173 @@ And our actual application:
94> _ <- execute conn "INSERT INTO species (species_name, species_genus) VALUES (?)" (speciesName, speciesGenus)
146> _ <- execute conn "INSERT INTO species (species_name, species_genus) VALUES (?)" (speciesName, speciesGenus)
95> return ()
147> return ()
96>
148>
97−> countSpecies :: Connection -> IO Int
149+> allSpecies :: Connection -> IO [Species]
98−> countSpecies conn = do
150+> allSpecies conn = do
99−> [Only count] <- query_ conn "SELECT count(*) FROM species"
151+> query_ conn "SELECT * FROM species"
100−> return count
101>
152>
102> main :: IO ()
153> main :: IO ()
103> main = do
154> main = do
104−> conn <- connectPostgreSQL ""
155+> conn <- connectPostgreSQL "dbname=servant-quickcheck"
105> run 8090 (serve api $ server conn)
156> run 8090 (serve api $ server conn)
106
157
107
158
159+(You'll also also need to run:
160+
161+```
162+createdb servant-quickcheck
163+psql --file schema.sql -d servant-quickcheck
164+```
165+
166+If you want to run this example.)
167+
168+This is a plausible effort. You might want to spend a moment thinking about what
169+could be improved.
170+
171+
172+> **Spec.hs**
173+
174+>
175+> {-# LANGUAGE OverloadedStrings #-}
176+> module Spec (main) where
177+>
178+> import Main (server, api, Species(..))
179+> import Test.Hspec
180+> import Test.QuickCheck.Instances
181+> import Servant.QuickCheck
182+> import Test.QuickCheck (Arbitrary(..))
183+> import Database.PostgreSQL.Simple (connectPostgreSQL)
184+>
185+> spec :: Spec
186+> spec = describe "the species application" $ beforeAll check $ do
187+> let pserver = do
188+> conn <- connectPostgreSQL "dbname=servant-quickcheck"
189+> return $ server conn
190+>
191+>
192+> it "should not return 500s" $ do
193+>
194+> it "should not return 500s" $ do
195+> withServantServer api pserver $ \url ->
196+> serverSatisfies api url defaultArgs (not500 <%> mempty)
197+>
198+> it "should not return top-level json" $ do
199+> withServantServer api pserver $ \url ->
200+> serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
201+>
202+> where
203+> check = do
204+> mvar <- newMVar []
205+> withServantServer api pserver $ \url ->
206+> serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
207+>
208+> main :: IO ()
209+> main = do
210+> hspec spec
211+>
212+> instance Arbitrary Species where
213+> arbitrary = Species <$> arbitrary <*> arbitrary
214+
215+
216+But this fails in quite a few ways.
217+
218+
219+<<TODO>>
220+
221+This was an example created with the knowledge of what it was supposed to
222+exemplify. To try to get a more accurate assessment of the practical usefulness
223+of `servant-quickcheck`, I tried running `serverSatisfies` with a few
224+predicates over some of the open-source `servant` servers I could find, and
225+results were also promising.
226+
227+There are probably a lot of other interesting properties that one might to add
228+besides those I've included. As an example, we could have a property that
229+all HTML is checked against, which is sometimes tricky for HTML that's
230+generated dynamically. Or check that every page has a Portuguese translation.
231+
232+### Why best practices are good
233+
234+As a side note: you might have wondered "why bother with API best practices?".
235+It is, it has to be said, a lot of extra (as in not only getting the feature done)
236+work to do, for dubious benefit. And indeed, the relevance of discoverability, for
237+example, unclear, since not that many tools use it as perhaps was anticipated.
238+
239+But `servant-quickcheck` both makes it *easier* to conform to best practices,
240+and exemplifies their advantage in enabling better tooling. If we pick 201 (Success, the 'resource' was
241+created), rather than the more generic 200 (Success), and do a *little* more work
242+by knowing to make this decision, `servant-quickcheck` knows this means there
243+should be some representation of the resource created. So it knows to ask you
244+for a link to it (the RFC creators thought to ask for this). And if you do (again,
245+a little more work), `servant-quickcheck` will know to try to look at that
246+resource by following the link, checking that it's not broken, and maybe even
247+returns a response that equivalent to the original POST request). And then it
248+finds a real bug - your application allows species with '/' in their name to
249+be created, but not queried with a 'GET' for! This, I think, is already a win.
250+
251+
252+## `serversEqual`
253+
254+There's another very appealing application of the ability to generate "sensible"
255+arbitrary requests. It's for testing that two applications are equal. We can generate arbitrary
256+requests, send them to both servers (in the same order), and check that the responses
257+are equivalent. (This was, incidentally, one of the first applications of
258+`servant-client`, albeit in a much more manual way, when we rewrote a microservice
259+originally in Python in Haskell.) Generally with rewrites, even if there's some
260+behaviour that isn't optimal, perhaps a lot of things already depend on that service
261+and make interace poorly with "improvements", so it makes sense to first mimick
262+*exactly* the original behaviour, and only then aim for improvements.
263+
264+`servant-quickcheck` provides a single function, `serversEqual`, that attempts
265+to verify the equivalence of servers. Since some aspects of responses might not
266+be relevant (for example, whether the the `Server` header is the same, or whether
267+two JSON responses have the same formatting), it allows you to provide a custom
268+equivalence function. Other than that, you need only provide an API type and two
269+URLs for testing, and the rest `serversEqual` handles.
270+
271+## Future directions: benchmarking
272+
273+What else could benefit from tooling that can automatically generate sensible
274+(*vis-a-vis* a particular application's expectations) requests?
275+
276+One area is extensive automatic benchmarking. Currently we use tools such as
277+`ab`, `wrk`, `httperf` in a very manual way - we pick a particular request that
278+we are interested in, and write a request that gets made thousands of times.
279+But now we can have a multiplicity of requests to benchmark with! This allows
280+*finding* slow endpoints, as well as (I would imagine, though I haven't actually
281+tried this yet) finding synchronization issues that make threads wait for too
282+long (such as waiting on an MVar that's not really needed), bad asymptotics
283+with respect to some other type of request.
284+
285+(On this last point, imagine not having an index in a database for "people",
286+ and having a tool that discovers that the latency on a search by first name
287+ grows linearly with the number of POST requests to a *different* endpoint! We'd
288+ need to do some work to do this well, possibly involving some machine
289+ learning, but it's an interesting and probably useful idea.)
290+
291+
292+# Conclusion
293+
294+I hope this library presents some useful functionality already, but I hope
295+you'll also think how it could be improved!
296+
297+There'll be a few more packages in the comings weeks - check back soon!
298+
299+**Note**: This post is an anansi literate file that generates multiple source
300+files. They are:
301+
302+
303+> ** Main.hs**
304+
305+> Main.hs
306+
307+
108
308
109−> **» Main.hs**
309+> ** schema.sql**
110
310
111−> «Main.hs»
311+> schema.sql
112
312
113
313
114
314
115−> **» schema.sql**
315+> ** Spec.hs**
116
316
117−> «schema.sql»
317+> Spec.hs
118
318
doc/posts/src/Main.hs+2−2
@@ -1,6 +1,6 @@
1−#line 158 "Announcement.anansi"
1+#line 296 "Announcement.anansi"
2
2
3−#line 37 "Announcement.anansi"
3+#line 86 "Announcement.anansi"
4{-# LANGUAGE DataKinds #-}
4{-# LANGUAGE DataKinds #-}
5{-# LANGUAGE DeriveAnyClass #-}
5{-# LANGUAGE DeriveAnyClass #-}
6{-# LANGUAGE DeriveGeneric #-}
6{-# LANGUAGE DeriveGeneric #-}
doc/posts/src/Spec.hs+9−3
@@ -1,6 +1,6 @@
1−#line 166 "Announcement.anansi"
1+#line 304 "Announcement.anansi"
2
2
3−#line 120 "Announcement.anansi"
3+#line 171 "Announcement.anansi"
4
4
5{-# LANGUAGE OverloadedStrings #-}
5{-# LANGUAGE OverloadedStrings #-}
6module Spec (main) where
6module Spec (main) where
@@ -13,11 +13,12 @@ import Test.QuickCheck (Arbitrary(..))
13import Database.PostgreSQL.Simple (connectPostgreSQL)
13import Database.PostgreSQL.Simple (connectPostgreSQL)
14
14
15spec :: Spec
15spec :: Spec
16−spec = describe "the species application" $ do
16+spec = describe "the species application" $ beforeAll check $ do
17 let pserver = do
17 let pserver = do
18 conn <- connectPostgreSQL "dbname=servant-quickcheck"
18 conn <- connectPostgreSQL "dbname=servant-quickcheck"
19 return $ server conn
19 return $ server conn
20
20
21+
21 it "should not return 500s" $ do
22 it "should not return 500s" $ do
22 withServantServer api pserver $ \url ->
23 withServantServer api pserver $ \url ->
23 serverSatisfies api url defaultArgs (not500 <%> mempty)
24 serverSatisfies api url defaultArgs (not500 <%> mempty)
@@ -26,6 +27,11 @@ spec = describe "the species application" $ do
26 withServantServer api pserver $ \url ->
27 withServantServer api pserver $ \url ->
27 serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
28 serverSatisfies api url defaultArgs (onlyJsonObjects <%> mempty)
28
29
30+ it "should return valid locations for 201" $ do
31+ withServantServer api pserver $ \url ->
32+ serverSatisfies api url defaultArgs (createContainsValidLocation <%> mempty)
33+
34+
29main :: IO ()
35main :: IO ()
30main = do
36main = do
31 hspec spec
37 hspec spec