├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── build └── build.sh ├── examples ├── failure │ ├── example.go │ ├── example_test.go │ └── hello.silk.md ├── google │ └── google-search.silk.md ├── real │ ├── google-search.silk.md │ └── hello-api.silk.md └── success │ ├── example.go │ ├── example_test.go │ └── hello.silk.md ├── main.go ├── other ├── SilkLogo-256.png ├── blog-preview.png ├── example.png ├── presentation │ ├── SilkLogo-256.png │ ├── example-rendered.png │ ├── example.silk.md │ └── silk.slide └── video-preview.jpg ├── parse ├── detail.go ├── doc.go ├── line.go ├── line_test.go ├── parser.go ├── parser_test.go ├── value.go └── value_test.go ├── runner ├── body.go ├── doc.go ├── run.go └── run_test.go ├── testfiles ├── failure │ ├── echo.failure.fieldsdifferenttypes.silk.md │ ├── echo.failure.fieldssametype.silk.md │ ├── echo.failure.nontrimmedexpectation.silk.md │ ├── echo.failure.wrongbody.silk.md │ ├── echo.failure.wrongheader.silk.md │ └── echoraw.failure.jsonmodes.silk.md └── success │ ├── body-as-field.silk.md │ ├── captured-vars.silk.md │ ├── comments.silk.md │ ├── comments2.silk.md │ ├── cookies.silk.md │ ├── data.silk.md │ ├── echo.nobody.success.silk.md │ ├── echo.success.silk.md │ ├── echoraw.success.jsonmodes.silk.md │ ├── example.silk.md │ ├── issue-31.silk.md │ └── issue-37.silk.md ├── testutil ├── doc.go └── echo.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | build/release/ 27 | 28 | silk -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | 5 | go: 6 | - 1.6.2 7 | - 1.7.1 8 | - tip 9 | 10 | before_install: 11 | - go get github.com/golang/lint/golint 12 | 13 | before_script: 14 | - go vet ./... 15 | - golint ./... 16 | 17 | script: 18 | - go test -v ./... 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Silk logo](https://github.com/matryer/silk/blob/master/other/SilkLogo-256.png) 2 | 3 | # silk [![Build Status](https://travis-ci.org/matryer/silk.svg?branch=master)](https://travis-ci.org/matryer/silk) [![Go Report Card](https://goreportcard.com/badge/github.com/matryer/silk)](https://goreportcard.com/report/github.com/matryer/silk) 4 | 5 | Markdown based document-driven web API testing. 6 | 7 | * Write nice looking Markdown documentation ([like this](https://github.com/matryer/silk/blob/master/testfiles/success/example.silk.md)), and then run it using the [silk command](#command-line) 8 | * Simple and robust [Markdown API](#markdown-api) 9 | * Comes with [real examples](https://github.com/matryer/silk/tree/master/testfiles/success) that you can copy (that are also part of the test suite for the project) 10 | * 10% discount on [LightPaper markdown editor app](http://lightpaper.42squares.in) for Silk users: use `SILKTEST` code. 11 | 12 | ## Learn more 13 | 14 | [![Video of Mat Ryer speaking about Silk](https://github.com/matryer/silk/blob/master/other/video-preview.jpg)](https://skillsmatter.com/skillscasts/7636-go-release#video) 15 | 16 | [(VIDEO) Watch the talk about Silk](https://skillsmatter.com/skillscasts/7636-go-release#video) (with [slides](http://go-talks.appspot.com/github.com/matryer/silk/other/presentation/silk.slide#1)) or [read about Silk in this blog post](https://medium.com/@matryer/introducing-silk-markdown-driven-api-tests-1f8cfb0ef99a#.kzpanz1xc). 17 | 18 | ![Example Silk test file](https://github.com/matryer/silk/blob/master/other/example.png) 19 | 20 | ## Markdown API 21 | 22 | Tests are made up of documents written in Markdown. 23 | 24 | * `# Group` - Top level headings represent groups of requests 25 | * `## GET /path` - Second level headings represent a request 26 | * Code blocks with three back tics represent bodies 27 | * `* Field: value` - Lists describe headers and assertions 28 | * `* ?param=value` - Request parameters 29 | * `---` seperators break requests from responses 30 | * Comments (starting with `//`) allow you to capture variables 31 | * Plain text is ignored to allow you to add documentation 32 | * Inline back tics are ignored and are available for formatting 33 | 34 | ### Document structure 35 | 36 | A document is made up of: 37 | 38 | * A request 39 | * `---` seperator 40 | * Assertions 41 | 42 | ### Requests 43 | 44 | A request starts with `##` and must have an HTTP method, and a path: 45 | 46 | ``` 47 | ## METHOD /path 48 | ``` 49 | 50 | Examples include: 51 | 52 | ``` 53 | ## GET /people 54 | 55 | ## POST /people/1/comments 56 | 57 | ## DELETE /people/1/comments/2 58 | 59 | ``` 60 | 61 | #### Request body (optional) 62 | 63 | To specify a request body (for example for `POST` requests) use a codeblock using backtics (` ``` `): 64 | 65 | ``` 66 | {"name": "Silk", "release_year": 2016} 67 | ``` 68 | 69 | #### Request headers (optional) 70 | 71 | You may specify request headers using lists (prefixed with `*`): 72 | 73 | ``` 74 | * Content-Type: "application/json" 75 | * X-Custom-Header: "123" 76 | ``` 77 | 78 | #### Request parameters (optional) 79 | 80 | Adding parameters to the path (like `GET /path?q=something`) can be tricky, especially when you consider escaping etc. To address this, Silk supports parameters like lists: 81 | 82 | ``` 83 | * ?param=value 84 | ``` 85 | 86 | The parameters will be correctly added to the URL path before the request is made. 87 | 88 | #### Cookies 89 | 90 | Setting cookies on a request can be done using the [HTTP header](https://en.wikipedia.org/wiki/HTTP_cookie#Implementation) pattern: 91 | 92 | ``` 93 | * Cookie: "key=value" 94 | ``` 95 | 96 | * See [asserting cookies](#asserting-cookies). 97 | 98 | ### Assertions 99 | 100 | Following the `---` separator, you can specify assertions about the response. At a minimum, it is recommended that you assert the status code to ensure the request succeeded: 101 | 102 | ``` 103 | * Status: 200 104 | ``` 105 | 106 | You may also specify response headers in the same format as request headers: 107 | 108 | ``` 109 | * Content-Type: "application/json" 110 | * X-MyServer-Version: "v1.0" 111 | ``` 112 | 113 | If any of the headers do not match, the test will fail. 114 | 115 | #### Capturing data 116 | 117 | Silk allows you to capture values at the point of asserting them and reuse them in future requests and assertions. To capture a value, include a comment on the line that mentions a `{placeholder}`: 118 | 119 | ``` 120 | * Data.UserID: /.*/ // The user's unique {id}. 121 | ``` 122 | 123 | The value from `UserID` (e.g. `123`) will be stored in a variable called `id`, and you can refer to it later: 124 | 125 | ``` 126 | ## GET /users/{id} 127 | ``` 128 | 129 | The above would be a request to `GET /users/123`. 130 | 131 | * Captured values are only available when assertions are successful 132 | 133 | #### Environment variables 134 | 135 | You can access environment variables inside Silk tests using the `{$NAME}` format, where `NAME` is the environment name. 136 | 137 | #### Asserting cookies 138 | 139 | To assert that a cookie is present in a response, make a regex assertion against the `Set-Cookie` HTTP header: 140 | 141 | ``` 142 | * Set-Cookie: /key=value/ 143 | ``` 144 | 145 | * All cookie strings are present in a single `Set-Cookie` seperated by a pipe character. 146 | 147 | #### Validating data 148 | 149 | You can optionally include a verbatim body using code blocks surrounded by three back tics. If the response body does not exactly match, the test will fail: 150 | 151 | ``` 152 | Hello world! 153 | ``` 154 | 155 | You can flag expected response bodies as `json` directly after the three back tics. 156 | This will assert that the actual response contains the same value for each expected key (recursively) 157 | allowing for differences in whitespace and ordering as well as being lenient towards additional (unexpected) keys in the response. 158 | 159 | ```json 160 | { 161 | "id": 1, 162 | "release_year": 2016, 163 | "name": "Silk" 164 | } 165 | ``` 166 | 167 | You can use the flag `json(strict)` to enforce that no additional fields may be present while still allowing for differences in whitespace and key order. 168 | 169 | You may also make any number of regex assertions against the body using the `Body` object: 170 | 171 | ``` 172 | * Body: /Hello world/ 173 | * Body: /This should be found too/ 174 | * Body: /and this/ 175 | ``` 176 | 177 | Alternatively, you can specify a list (using `*`) of data fields to assert accessible via the `Data` object: 178 | 179 | ``` 180 | * Status: 201 181 | * Content-Type: "application/json" 182 | * Data.name: "Silk" 183 | * Data.release_year: 2016 184 | * Data.tags[0]: "testing" 185 | * Data.tags[1]: "markdown" 186 | * Data[0].name: "Mat" 187 | * Data[1].name: "David" 188 | ``` 189 | 190 | * NOTE: Currenly this feature is only supported for JSON APIs. 191 | 192 | #### Regex 193 | 194 | Values may be regex, if they begin and end with a forward slash: `/`. The assertion will pass if the value (after being turned into a string) matches the regex. 195 | 196 | ``` 197 | * Status: /^2.{2}$/ 198 | * Content-Type: /application/json/ 199 | ``` 200 | 201 | The above will assert that: 202 | 203 | * The status looks like `2xx`, and 204 | * The `Content-Type` contains `application/json` 205 | 206 | ## Command line 207 | 208 | The `silk` command runs tests against an HTTP endpoint. 209 | 210 | Usage: 211 | 212 | ``` 213 | silk -silk.url="{endpoint}" {testfiles...} 214 | ``` 215 | 216 | * `{endpoint}` the endpoint URL (e.g. `http://localhost:8080`) 217 | * `{testfiles}` list of test files (e.g. `./testfiles/one.silk.md ./testfiles/two.silk.md`) 218 | 219 | Notes: 220 | 221 | * Omit trailing slash from `endpoint` 222 | * `{testfiles}` can include a pattern (e.g. `/path/*.silk.md`) as this is expended by most terminals to a list of matching files 223 | 224 | ## Golang 225 | 226 | Silk is written in Go and integrates seamlessly into existing testing tools and frameworks. Import the `runner` package and use `RunGlob` to match many test files: 227 | 228 | ``` 229 | package project_test 230 | 231 | import ( 232 | "testing" 233 | "github.com/matryer/silk/runner" 234 | ) 235 | 236 | func TestAPIEndpoint(t *testing.T) { 237 | // start a server 238 | s := httptest.NewServer(yourHandler) 239 | defer s.Close() 240 | 241 | // run all test files 242 | runner.New(t, s.URL).RunGlob(filepath.Glob("../testfiles/failure/*.silk.md")) 243 | } 244 | ``` 245 | 246 | * See the [documentation for the silk/runner package](https://godoc.org/github.com/matryer/silk/runner) 247 | 248 | ## Credit 249 | 250 | * Special thanks to [@dahernan](https://github.com/dahernan) for his contributions and criticisms of Silk 251 | * Silk logo by [Chris Ryer](http://chrisryer.co.uk) 252 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | LOC=`pwd`/../ 6 | HERE="build/release" 7 | LASTCOMMIT=`git rev-parse HEAD` 8 | LASTCOMMITSHORT=`git rev-parse --short HEAD` 9 | echo "Building https://github.com/matryer/silk/commit/$LASTCOMMIT..." 10 | 11 | cd $LOC 12 | rm -rf $HERE 13 | mkdir $HERE 14 | VERSION=`cat version.go | grep version | awk -F'"' '{print $2}'` 15 | echo "Version: $VERSION" 16 | 17 | function build { 18 | echo " for $1 $2..." 19 | echo " (building)" 20 | thisdir=silk-$VERSION-$1-$2 21 | GOOS=$1 GOARCH=$2 go build -o $HERE/$dir/$thisdir/silk 22 | echo "Version $VERSION - https://github.com/matryer/silk/commit/$LASTCOMMIT" > $HERE/$dir/$thisdir/README.md 23 | echo " (compressing)" 24 | cd $HERE 25 | zip $thisdir.zip $thisdir/* 26 | cd $LOC 27 | echo " (cleaning up)" 28 | rm -rf $HERE/$thisdir 29 | echo " (done)" 30 | } 31 | 32 | #build darwin 386 33 | build darwin amd64 34 | #build darwin arm 35 | #build dragonfly amd64 36 | #build freebsd 386 37 | #build freebsd amd64 38 | #build freebsd arm 39 | #build linux 386 40 | build linux amd64 41 | #build linux arm 42 | build linux arm64 43 | #build linux ppc64 44 | #build linux ppc64le 45 | #build netbsd 386 46 | #build netbsd amd64 47 | #build netbsd arm 48 | #build openbsd 386 49 | #build openbsd amd64 50 | #build openbsd arm 51 | #build plan9 386 52 | #build plan9 amd64 53 | #build solaris amd64 54 | #build windows 386 55 | build windows amd64 56 | 57 | echo "All done." -------------------------------------------------------------------------------- /examples/failure/example.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // NewServer makes a new example server. 11 | func NewServer() http.Handler { 12 | r := mux.NewRouter() 13 | r.Path("/hello").Methods("GET").HandlerFunc(handleHello) 14 | return r 15 | } 16 | 17 | func handleHello(w http.ResponseWriter, r *http.Request) { 18 | q := r.URL.Query() 19 | fmt.Fprintf(w, "Hello %s.", q.Get("name")) 20 | } 21 | -------------------------------------------------------------------------------- /examples/failure/example_test.go: -------------------------------------------------------------------------------- 1 | package example_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | example "github.com/matryer/silk/examples/failure" 8 | ) 9 | 10 | func TestHello(t *testing.T) { 11 | 12 | // start test server 13 | server := httptest.NewServer(example.NewServer()) 14 | defer server.Close() 15 | 16 | // make a new runner using the server URL as the target 17 | // and run the test file. 18 | 19 | //runner.New(t, server.URL).RunFile("hello.silk.md") 20 | 21 | // NOTE: Uncomment the line above to see a real failure. 22 | // Is it commented out so the tests don't fail :) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /examples/failure/hello.silk.md: -------------------------------------------------------------------------------- 1 | # Hello server 2 | 3 | ## GET /hello?name=Silk 4 | 5 | === 6 | 7 | ``` 8 | Hello Silky. 9 | ``` -------------------------------------------------------------------------------- /examples/google/google-search.silk.md: -------------------------------------------------------------------------------- 1 | # Google search 2 | 3 | This can be run with `silk -silk.url="https://www.google.co.uk"` 4 | 5 | ## `GET /search#q=test` 6 | 7 | Perform a search for the word `test`. 8 | 9 | === 10 | 11 | * `Status: 200` 12 | * `Server: gws` 13 | * `Body: /test/` -------------------------------------------------------------------------------- /examples/real/google-search.silk.md: -------------------------------------------------------------------------------- 1 | # Google search test 2 | 3 | ## GET /#q=silk 4 | 5 | === 6 | 7 | Status: 200 8 | -------------------------------------------------------------------------------- /examples/real/hello-api.silk.md: -------------------------------------------------------------------------------- 1 | # Hello API 2 | 3 | The Hello API just says hello to people, in a very polite way. 4 | 5 | ## `GET /hello` 6 | 7 | Gets a personalised greeting. 8 | 9 | * `?name=Mat` // The name of the person to greet 10 | 11 | === 12 | 13 | ### Example response 14 | 15 | * Status: `200` 16 | * Content-Type: `text/html; charset=utf-8` 17 | 18 | Returns the text greeting: 19 | 20 | ``` 21 | Hello Mat. 22 | ``` 23 | -------------------------------------------------------------------------------- /examples/success/example.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | // NewServer makes a new example Server. 11 | func NewServer() http.Handler { 12 | r := mux.NewRouter() 13 | r.Path("/hello").Methods("GET").HandlerFunc(handleHello) 14 | return r 15 | } 16 | 17 | func handleHello(w http.ResponseWriter, r *http.Request) { 18 | q := r.URL.Query() 19 | fmt.Fprintf(w, "Hello %s.", q.Get("name")) 20 | } 21 | -------------------------------------------------------------------------------- /examples/success/example_test.go: -------------------------------------------------------------------------------- 1 | package example_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | example "github.com/matryer/silk/examples/success" 8 | "github.com/matryer/silk/runner" 9 | ) 10 | 11 | func TestHello(t *testing.T) { 12 | 13 | // start test server 14 | server := httptest.NewServer(example.NewServer()) 15 | defer server.Close() 16 | 17 | // make a new runner using the server URL as the target 18 | // and run the test file. 19 | runner.New(t, server.URL).RunFile("hello.silk.md") 20 | 21 | } 22 | -------------------------------------------------------------------------------- /examples/success/hello.silk.md: -------------------------------------------------------------------------------- 1 | # Hello server 2 | 3 | ## GET /hello?name=Silk 4 | 5 | === 6 | 7 | ``` 8 | Hello Silk. 9 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/matryer/silk/runner" 9 | ) 10 | 11 | var ( 12 | showVersion = flag.Bool("version", false, "show version and exit") 13 | url = flag.String("silk.url", "", "(required) target url") 14 | help = flag.Bool("help", false, "show help") 15 | paths []string 16 | ) 17 | 18 | func main() { 19 | flag.Parse() 20 | if *showVersion { 21 | printversion() 22 | return 23 | } 24 | if *help { 25 | printhelp() 26 | return 27 | } 28 | if *url == "" { 29 | fmt.Println("silk.url argument is required") 30 | return 31 | } 32 | paths = flag.Args() 33 | testing.Main(func(pat, str string) (bool, error) { return true, nil }, 34 | []testing.InternalTest{{Name: "silk", F: testFunc}}, 35 | nil, 36 | nil) 37 | } 38 | 39 | func testFunc(t *testing.T) { 40 | r := runner.New(t, *url) 41 | fmt.Println("silk: running", len(paths), "file(s)...") 42 | r.RunGlob(paths, nil) 43 | } 44 | 45 | func printhelp() { 46 | printversion() 47 | fmt.Println("usage: silk [file] [file2 [file3 [...]]") 48 | fmt.Println(" e.g: silk ./test/*.silk.md") 49 | flag.PrintDefaults() 50 | } 51 | 52 | func printversion() { 53 | fmt.Println("silk", version) 54 | } 55 | -------------------------------------------------------------------------------- /other/SilkLogo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/SilkLogo-256.png -------------------------------------------------------------------------------- /other/blog-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/blog-preview.png -------------------------------------------------------------------------------- /other/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/example.png -------------------------------------------------------------------------------- /other/presentation/SilkLogo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/presentation/SilkLogo-256.png -------------------------------------------------------------------------------- /other/presentation/example-rendered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/presentation/example-rendered.png -------------------------------------------------------------------------------- /other/presentation/example.silk.md: -------------------------------------------------------------------------------- 1 | # Hello API 2 | 3 | The Hello API just says hello to people, in a very polite way. 4 | 5 | ## `GET /hello` 6 | 7 | Gets a personalised greeting. 8 | 9 | * `?name=Mat` // The name of the person to greet 10 | 11 | === 12 | 13 | ### Example response 14 | 15 | * Status: `200` 16 | * Content-Type: `text/plain; charset=utf-8` 17 | 18 | Returns the text greeting: 19 | 20 | ``` 21 | Hello Mat. 22 | ``` 23 | -------------------------------------------------------------------------------- /other/presentation/silk.slide: -------------------------------------------------------------------------------- 1 | Introducing Silk 2 | Go 1.6 Release Party 3 | 17 Feb 2016 4 | 5 | Mat Ryer 6 | http://silktest.org/ 7 | @matryer 8 | 9 | * The problem 10 | 11 | - Testing RESTful APIs is easy, but easily gets messy 12 | - Existing tools do too much, and cost money 13 | - Documentation and automated tests are different things 14 | - Difficult to TDD RESTful APIs 15 | - Handler unit tests don't _always_ test middleware 16 | 17 | * What is Silk? 18 | 19 | .image SilkLogo-256.png 20 | 21 | Markdown based document-driven RESTful API testing 22 | 23 | (Va como la seda) 24 | 25 | * Silk features 26 | 27 | - Markdown test files are documentation AND executable tests 28 | - Describe the requests and assert things about the responses 29 | - Assertions are examples in the docs 30 | - Most text is ignored so you can describe things to users 31 | - Not much new to learn (if anything) 32 | - Looks great in GitHub 33 | - Run tests locally, with `httptest` or on live site (any host) 34 | - `runner` package for Gophers that uses `testing.T` 35 | 36 | * Example 37 | 38 | .code example.silk.md 39 | 40 | * Example rendered 41 | 42 | .image example-rendered.png 43 | 44 | * Markdown format 45 | 46 | - # Group - Top level headings represent groups of requests 47 | - ## GET /path - Second level headings represent a request 48 | - Code blocks with ```````` three back tics represent bodies 49 | - * Field: value - Lists describe headers and assertions 50 | - * ?param=value - Request parameters 51 | - === seperators break requests from responses 52 | - Comments (starting with //) are ignored 53 | - Plain text is ignored to allow you to add documentation 54 | - Inline back tics are ignored and are available for formatting 55 | 56 | * Silk tool 57 | 58 | $ silk -silk.url="https://outlearn-hello.appspot.com" 59 | 60 | running 1 file(s) 61 | body expected: 62 | ``` 63 | Hello Silky. 64 | ``` 65 | actual: 66 | ``` 67 | Hello Silk. 68 | ``` 69 | --- FAIL: GET /hello?name=Silk 70 | hello.silk.md:8 - body doesn't match 71 | --- FAIL: silk (0.42s) 72 | FAIL 73 | 74 | * In Go 75 | 76 | package server_test 77 | 78 | import ( 79 | "testing" 80 | "net/http/httptest" 81 | "github.com/matryer/silk/runner" 82 | ) 83 | 84 | func TestHello(t *testing.T) { 85 | 86 | // start test server 87 | server := httptest.NewServer(MyHandler()) 88 | defer server.Close() 89 | 90 | // make a new runner using the server URL as the target 91 | // and run the test file. 92 | runner.New(t, server.URL).RunFile("hello.silk.md") 93 | 94 | } 95 | 96 | Familiar setup and teardown 97 | 98 | * Tips and tricks 99 | 100 | - JSON bodies are understood 101 | 102 | * Data.name: "Mat" 103 | * Data.package: "Silk" 104 | * Data.tags[0]: "golang" 105 | * Data.tags[1]: "testing" 106 | 107 | - Extend to any format by overriding `ParseBody` function on runner 108 | - Override `NewRequest` and `RoundTripper` fields in runner 109 | - It works on App Engine apps too 110 | - Supports regexp: 111 | 112 | * Status: /^2.{2}$/ 113 | * Content-Type: /application/json/ 114 | 115 | * Get involved? 116 | 117 | - Ideas? 118 | - Help us get to v1 release 119 | - Visit [[http://silktest.org]] or go to [[https://github.com/matryer/silk]] 120 | -------------------------------------------------------------------------------- /other/video-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matryer/silk/11c4f5465bf617bd7ff879e7e243c9180ca35528/other/video-preview.jpg -------------------------------------------------------------------------------- /parse/detail.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "regexp" 9 | ) 10 | 11 | // Detail is a key/value pair used for parameters 12 | // and assertions. 13 | type Detail struct { 14 | Key string 15 | Value *Value 16 | } 17 | 18 | func parseDetail(b []byte, detailregex *regexp.Regexp) (*Detail, error) { 19 | detail, err := getok(detailregex.FindSubmatch(b), 1) 20 | if err != nil { 21 | panic("silk: failed to parse detail: " + err.Error()) 22 | } 23 | sep := bytes.IndexAny(detail, ":=") 24 | if sep == -1 || sep > len(detail)-1 { 25 | return nil, errors.New("malformed detail") 26 | } 27 | key := clean(detail[0:sep]) 28 | return &Detail{ 29 | Key: string(bytes.TrimSpace(key)), 30 | Value: ParseValue(detail[sep+1:]), 31 | }, nil 32 | } 33 | 34 | func (d *Detail) String() string { 35 | valbytes, err := json.Marshal(d.Value.Data) 36 | if err != nil { 37 | return d.Key + ": " + fmt.Sprint(d.Value) 38 | } 39 | return d.Key + ": " + string(valbytes) 40 | } 41 | 42 | func clean(b []byte) []byte { 43 | return bytes.Trim(bytes.TrimSpace(b), "`") 44 | } 45 | -------------------------------------------------------------------------------- /parse/doc.go: -------------------------------------------------------------------------------- 1 | // Package parse provides low-level tools for parsing Silk files. 2 | package parse 3 | -------------------------------------------------------------------------------- /parse/line.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | ) 9 | 10 | var ( 11 | commentPrefix = []byte(` //`) 12 | ) 13 | 14 | // Line represents a single line. 15 | type Line struct { 16 | Number int 17 | Type LineType 18 | Bytes []byte 19 | Comment []byte 20 | Regexp *regexp.Regexp 21 | detail *Detail 22 | } 23 | 24 | // ParseLine makes a new Line with the given data. 25 | func ParseLine(n int, unsafeText []byte) (*Line, error) { 26 | linetype := LineTypePlain 27 | // trim off comments 28 | var comment []byte 29 | text := make([]byte, len(unsafeText)) 30 | copy(text, unsafeText) 31 | if bytes.Contains(text, commentPrefix) { 32 | segs := bytes.Split(text, commentPrefix) 33 | text = segs[0] 34 | comment = segs[1] 35 | } 36 | var rx *regexp.Regexp 37 | for _, item := range matchTypes { 38 | if regexes[item.R].Match(text) { 39 | linetype = item.Type 40 | rx = regexes[item.R] 41 | break 42 | } 43 | } 44 | // parse the detail now 45 | var d *Detail 46 | if linetype == LineTypeDetail || linetype == LineTypeParam { 47 | var err error 48 | d, err = parseDetail(text, rx) 49 | if err != nil { 50 | return nil, &ErrLine{N: n, Err: err} 51 | } 52 | } 53 | return &Line{ 54 | Number: n, 55 | Type: linetype, 56 | Bytes: text, 57 | Regexp: rx, 58 | Comment: comment, 59 | detail: d, 60 | }, nil 61 | } 62 | 63 | func (l *Line) String() string { 64 | return fmt.Sprintf("%d: (%s) %s", l.Number, l.Type, string(l.Bytes)) 65 | } 66 | 67 | // Detail gets the Detail from this Line. 68 | func (l *Line) Detail() *Detail { 69 | return l.detail 70 | } 71 | 72 | var placeholderRegexp = regexp.MustCompile(`{(.*)}`) 73 | 74 | // Capture extracts the first placeholder value from the 75 | // comments. 76 | func (l *Line) Capture() string { 77 | if len(l.Comment) == 0 { 78 | return "" 79 | } 80 | matches := placeholderRegexp.FindSubmatch(l.Comment) 81 | if len(matches) < 2 { 82 | return "" 83 | } 84 | return string(matches[1]) 85 | } 86 | 87 | // Lines represents many lines. 88 | type Lines []*Line 89 | 90 | // Bytes gets the joined bytes of all lines. 91 | func (l Lines) Bytes() []byte { 92 | var lines [][]byte 93 | for _, line := range l { 94 | lines = append(lines, line.Bytes) 95 | } 96 | return bytes.Join(lines, []byte("\n")) 97 | } 98 | 99 | func (l Lines) String() string { 100 | return string(l.Bytes()) 101 | } 102 | 103 | // Reader makes a new io.Reader that will read the 104 | // bytes from every line. 105 | func (l Lines) Reader() io.Reader { 106 | return bytes.NewReader(l.Bytes()) 107 | } 108 | 109 | // Number gets the line number of the first line. 110 | func (l Lines) Number() int { 111 | if len(l) == 0 { 112 | return 0 113 | } 114 | return l[0].Number 115 | } 116 | 117 | // LineType represents the type of a line. 118 | type LineType int8 119 | 120 | // LineTypes 121 | const ( 122 | LineTypePlain LineType = iota 123 | LineTypeGroupHeading 124 | LineTypeRequest 125 | LineTypeCodeBlock 126 | LineTypeDetail 127 | LineTypeSeparator 128 | LineTypeParam 129 | ) 130 | 131 | var lineTypeStrs = map[LineType]string{ 132 | LineTypePlain: "plain", 133 | LineTypeGroupHeading: "heading", 134 | LineTypeRequest: "request", 135 | LineTypeCodeBlock: "codeblock", 136 | LineTypeDetail: "detail", 137 | LineTypeSeparator: "separator", 138 | LineTypeParam: "param", 139 | } 140 | 141 | func (l LineType) String() string { 142 | return lineTypeStrs[l] 143 | } 144 | 145 | // matchTypes map patterns to types. 146 | // Prescedence is important. 147 | var matchTypes = []struct { 148 | R string 149 | Type LineType 150 | }{{ 151 | // ## GET /comments 152 | R: "^## (.*) (.*)", 153 | Type: LineTypeRequest, 154 | }, { 155 | // # Heading 156 | R: "^# (.*)", 157 | Type: LineTypeGroupHeading, 158 | }, { 159 | // ``` 160 | R: "^```", 161 | Type: LineTypeCodeBlock, 162 | }, { 163 | // === 164 | R: "^(===+)", 165 | Type: LineTypeSeparator, 166 | }, { 167 | // --- 168 | R: "^(---+)", 169 | Type: LineTypeSeparator, 170 | }, { 171 | // * ?param=value 172 | R: "^\\s*\\* `?\\?(.*=?.*)`?", 173 | Type: LineTypeParam, 174 | }, { 175 | // * Content-Type: application/json 176 | R: "^\\s*\\* (.*)", 177 | Type: LineTypeDetail, 178 | }} 179 | 180 | var regexes map[string]*regexp.Regexp 181 | 182 | func init() { 183 | // compile regexes 184 | regexes = make(map[string]*regexp.Regexp, len(matchTypes)) 185 | for _, item := range matchTypes { 186 | regexes[item.R] = regexp.MustCompile(item.R) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /parse/line_test.go: -------------------------------------------------------------------------------- 1 | package parse_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/cheekybits/is" 8 | "github.com/matryer/silk/parse" 9 | ) 10 | 11 | func TestParseLine(t *testing.T) { 12 | is := is.New(t) 13 | 14 | var tests = []struct { 15 | Src string 16 | Type parse.LineType 17 | }{{ 18 | Src: "", 19 | Type: parse.LineTypePlain, 20 | }, { 21 | Src: "Normal text is just considered plain.", 22 | Type: parse.LineTypePlain, 23 | }, { 24 | Src: "# Heading", 25 | Type: parse.LineTypeGroupHeading, 26 | }, { 27 | Src: "## `POST /something`", 28 | Type: parse.LineTypeRequest, 29 | }, { 30 | Src: "## POST /echo", 31 | Type: parse.LineTypeRequest, 32 | }, { 33 | Src: "### Example request", 34 | Type: parse.LineTypePlain, 35 | }, { 36 | Src: "* `Detail`: `123`", 37 | Type: parse.LineTypeDetail, 38 | }, { 39 | Src: "* `?param=value`", 40 | Type: parse.LineTypeParam, 41 | }, { 42 | Src: " * `Detail`: `123`", 43 | Type: parse.LineTypeDetail, 44 | }, { 45 | Src: " * `?param=value`", 46 | Type: parse.LineTypeParam, 47 | }, { 48 | Src: "* ?param=value", 49 | Type: parse.LineTypeParam, 50 | }, { 51 | Src: "* Cookie: name=value", 52 | Type: parse.LineTypeDetail, 53 | }, { 54 | Src: `* Set-Cookie: "another=true"`, 55 | Type: parse.LineTypeDetail, 56 | }, { 57 | Src: "===", 58 | Type: parse.LineTypeSeparator, 59 | }, { 60 | Src: "====", 61 | Type: parse.LineTypeSeparator, 62 | }, { 63 | Src: "=====", 64 | Type: parse.LineTypeSeparator, 65 | }, { 66 | Src: "---", 67 | Type: parse.LineTypeSeparator, 68 | }, { 69 | Src: "----", 70 | Type: parse.LineTypeSeparator, 71 | }, { 72 | Src: "-----", 73 | Type: parse.LineTypeSeparator, 74 | }} 75 | for i, test := range tests { 76 | l, err := parse.ParseLine(i, []byte(test.Src)) 77 | is.NoErr(err) 78 | is.Equal(l.Type, test.Type) 79 | is.Equal(l.Bytes, []byte(test.Src)) 80 | is.Equal(l.Number, i) 81 | } 82 | 83 | } 84 | 85 | func TestLineComments(t *testing.T) { 86 | is := is.New(t) 87 | l, err := parse.ParseLine(0, []byte(`* Key: "Value" // comments should be ignored`)) 88 | is.NoErr(err) 89 | is.Equal(l.Comment, " comments should be ignored") 90 | detail := l.Detail() 91 | is.OK(detail) 92 | is.Equal(detail.Key, "Key") 93 | is.Equal(detail.Value.Data, "Value") 94 | l, err = parse.ParseLine(0, []byte(`* Key: "Value" // comments should be ignored`)) 95 | is.NoErr(err) 96 | is.Equal(string(l.Bytes), `* Key: "Value"`) 97 | 98 | // if the comment contains a {placeholder}, grab it 99 | l, err = parse.ParseLine(0, []byte(`* Key: "Value" // will be stored as {id}`)) 100 | is.NoErr(err) 101 | is.Equal(l.Comment, " will be stored as {id}") 102 | is.Equal(l.Capture(), "id") 103 | } 104 | 105 | func TestLineParams(t *testing.T) { 106 | is := is.New(t) 107 | for i, line := range []string{ 108 | "* ?key=value", 109 | "* `?key=value`", 110 | "* ?`key`=`value`", 111 | } { 112 | l, err := parse.ParseLine(i, []byte(line)) 113 | is.NoErr(err) 114 | is.Equal(l.Type, parse.LineTypeParam) 115 | detail := l.Detail() 116 | is.OK(detail) 117 | is.Equal(detail.Key, "key") 118 | is.Equal(detail.Value.Data, "value") 119 | } 120 | } 121 | 122 | func TestLineDetail(t *testing.T) { 123 | is := is.New(t) 124 | l, err := parse.ParseLine(0, []byte(`* Key-Here: "Value"`)) 125 | is.NoErr(err) 126 | detail := l.Detail() 127 | is.Equal(detail.Key, "Key-Here") 128 | is.Equal(detail.Value.Data, "Value") 129 | } 130 | 131 | func TestLinesReader(t *testing.T) { 132 | is := is.New(t) 133 | 134 | var lines parse.Lines 135 | 136 | l, err := parse.ParseLine(0, []byte("Line one ")) 137 | is.NoErr(err) 138 | lines = append(lines, l) 139 | l, err = parse.ParseLine(1, []byte("Line two ")) 140 | is.NoErr(err) 141 | lines = append(lines, l) 142 | l, err = parse.ParseLine(2, []byte("Line three")) 143 | is.NoErr(err) 144 | lines = append(lines, l) 145 | 146 | out, err := ioutil.ReadAll(lines.Reader()) 147 | is.NoErr(err) 148 | is.Equal(string(out), `Line one 149 | Line two 150 | Line three`) 151 | 152 | } 153 | 154 | func TestLinesReaderMutableBytes(t *testing.T) { 155 | is := is.New(t) 156 | 157 | lineText := "This line shouldn't change after parsing" 158 | mutableData := []byte(lineText) 159 | 160 | l, err := parse.ParseLine(0, mutableData) 161 | is.NoErr(err) 162 | 163 | mutableData[5] = 'w' 164 | 165 | is.Equal(lineText, string(l.Bytes)) 166 | } 167 | -------------------------------------------------------------------------------- /parse/parser.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | ) 11 | 12 | var ( 13 | errMissingGroupHeader = errors.New("missing group header") 14 | errUnexpectedCodeblock = errors.New("unexpected codeblock") 15 | errMissingEndCodeblock = errors.New("missing end codeblock") 16 | errUnexpectedDetails = errors.New("unexpected details") 17 | errUnexpectedParams = errors.New("unexpected params") 18 | errMalformedDetail = errors.New("malformed detail") 19 | ) 20 | 21 | // Group represents a group of Requests. 22 | type Group struct { 23 | Filename string 24 | Title []byte 25 | Requests []*Request 26 | Details Lines 27 | } 28 | 29 | // Request describes an HTTP request and a set of 30 | // associated assertions. 31 | type Request struct { 32 | Path []byte 33 | Method []byte 34 | Details Lines 35 | Params Lines 36 | Body Lines 37 | BodyType string 38 | //=== 39 | ExpectedBody Lines 40 | ExpectedBodyType string 41 | ExpectedDetails Lines 42 | } 43 | 44 | // ErrLine describes an error at a specific line. 45 | type ErrLine struct { 46 | N int 47 | Err error 48 | } 49 | 50 | func (e ErrLine) Error() string { 51 | return fmt.Sprintf("%d: %v", e.N, e.Err) 52 | } 53 | 54 | // ParseFile parses the specified files. 55 | func ParseFile(files ...string) ([]*Group, error) { 56 | var groups []*Group 57 | for _, file := range files { 58 | if err := func(file string) error { 59 | f, err := os.Open(file) 60 | if err != nil { 61 | return err 62 | } 63 | defer f.Close() 64 | gs, err := Parse(file, f) 65 | if err != nil { 66 | return err 67 | } 68 | groups = append(groups, gs...) 69 | return nil 70 | }(file); err != nil { 71 | return nil, err 72 | } 73 | } 74 | return groups, nil 75 | } 76 | 77 | // Parse parses a file. 78 | func Parse(filename string, r io.Reader) ([]*Group, error) { 79 | 80 | n := 0 81 | groups := make([]*Group, 0) 82 | scanner := bufio.NewScanner(r) 83 | 84 | // whether we're at the point of expectations or 85 | // not. 86 | settingExpectations := false 87 | 88 | var currentGroup *Group 89 | var currentRequest *Request 90 | 91 | for scanner.Scan() { 92 | n++ 93 | line, err := ParseLine(n, scanner.Bytes()) 94 | if err != nil { 95 | return nil, err 96 | } 97 | switch line.Type { 98 | case LineTypeGroupHeading: 99 | // new group 100 | if currentGroup != nil { 101 | if currentRequest != nil { 102 | currentGroup.Requests = append(currentGroup.Requests, currentRequest) 103 | currentRequest = nil 104 | } 105 | groups = append(groups, currentGroup) 106 | } 107 | title, err := getok(line.Regexp.FindSubmatch(line.Bytes), 1) 108 | if err != nil { 109 | return nil, &ErrLine{N: n, Err: err} 110 | } 111 | currentGroup = &Group{ 112 | Filename: filename, 113 | Title: title, 114 | } 115 | case LineTypeRequest: 116 | // new request 117 | if currentGroup == nil { 118 | return nil, &ErrLine{N: n, Err: errMissingGroupHeader} 119 | } 120 | if currentRequest != nil { 121 | currentGroup.Requests = append(currentGroup.Requests, currentRequest) 122 | } 123 | settingExpectations = false 124 | var err error 125 | currentRequest = &Request{} 126 | matches := line.Regexp.FindSubmatch(line.Bytes) 127 | if currentRequest.Method, err = getok(matches, 1); err != nil { 128 | return nil, &ErrLine{N: n, Err: err} 129 | } 130 | if currentRequest.Path, err = getok(matches, 2); err != nil { 131 | return nil, &ErrLine{N: n, Err: err} 132 | } 133 | case LineTypeCodeBlock: 134 | 135 | if currentRequest == nil { 136 | return nil, &ErrLine{N: n, Err: errUnexpectedCodeblock} 137 | } 138 | 139 | var bodyType string 140 | if len(line.Bytes) > 3 { 141 | bodyType = string(line.Bytes[3:]) 142 | } 143 | 144 | var lines Lines 145 | var err error 146 | n, lines, err = scancodeblock(n, scanner) 147 | if err != nil { 148 | return nil, &ErrLine{N: n, Err: err} 149 | } 150 | if settingExpectations { 151 | currentRequest.ExpectedBody = lines 152 | currentRequest.ExpectedBodyType = bodyType 153 | } else { 154 | currentRequest.Body = lines 155 | currentRequest.BodyType = bodyType 156 | } 157 | 158 | case LineTypeDetail: 159 | if currentRequest == nil && currentGroup == nil { 160 | return nil, &ErrLine{N: n, Err: errUnexpectedDetails} 161 | } 162 | if currentRequest == nil { 163 | currentGroup.Details = append(currentGroup.Details, line) 164 | continue 165 | } 166 | if settingExpectations { 167 | currentRequest.ExpectedDetails = append(currentRequest.ExpectedDetails, line) 168 | } else { 169 | currentRequest.Details = append(currentRequest.Details, line) 170 | } 171 | case LineTypeParam: 172 | if currentRequest == nil && currentGroup == nil { 173 | return nil, &ErrLine{N: n, Err: errUnexpectedParams} 174 | } 175 | if settingExpectations { 176 | return nil, &ErrLine{N: n, Err: errUnexpectedParams} 177 | } 178 | currentRequest.Params = append(currentRequest.Params, line) 179 | case LineTypeSeparator: 180 | settingExpectations = true 181 | } 182 | 183 | } 184 | 185 | if currentGroup == nil { 186 | return nil, &ErrLine{N: n, Err: errMissingGroupHeader} 187 | } 188 | if currentRequest != nil { 189 | currentGroup.Requests = append(currentGroup.Requests, currentRequest) 190 | } 191 | groups = append(groups, currentGroup) 192 | 193 | return groups, nil 194 | } 195 | 196 | func scancodeblock(n int, scanner *bufio.Scanner) (int, Lines, error) { 197 | var lines Lines 198 | for scanner.Scan() { 199 | n++ 200 | line, err := ParseLine(n, scanner.Bytes()) 201 | if err != nil { 202 | return n, nil, err 203 | } 204 | if line.Type == LineTypeCodeBlock { 205 | // we're done 206 | return n, lines, nil 207 | } 208 | lines = append(lines, line) 209 | } 210 | // shouldn't reach the end 211 | return n, lines, errMissingEndCodeblock 212 | } 213 | 214 | func getok(src [][]byte, i int) ([]byte, error) { 215 | if i+1 > len(src) { 216 | return nil, fmt.Errorf("bad format: expected at least %d regex matches, but was %d: %s", i+1, len(src), string(bytes.Join(src, []byte("\n")))) 217 | } 218 | return clean(src[i]), nil 219 | } 220 | -------------------------------------------------------------------------------- /parse/parser_test.go: -------------------------------------------------------------------------------- 1 | package parse_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cheekybits/is" 7 | "github.com/matryer/silk/parse" 8 | ) 9 | 10 | func TestParser(t *testing.T) { 11 | is := is.New(t) 12 | 13 | groups, err := parse.ParseFile("../testfiles/success/comments.silk.md", "../testfiles/success/comments2.silk.md") 14 | is.NoErr(err) 15 | 16 | is.Equal(len(groups), 3) 17 | is.Equal(groups[0].Filename, "../testfiles/success/comments.silk.md") 18 | is.Equal(groups[1].Filename, "../testfiles/success/comments.silk.md") 19 | is.Equal(groups[2].Filename, "../testfiles/success/comments2.silk.md") 20 | is.Equal(groups[0].Title, "Comments and things") 21 | is.Equal(groups[1].Title, "Another group") 22 | 23 | group := groups[0] 24 | is.Equal(len(group.Requests), 2) 25 | is.Equal(len(group.Details), 1) 26 | is.Equal(group.Details[0].Detail().Key, "Root") 27 | is.Equal(group.Details[0].Detail().Value.Data, "http://localhost:8080/") 28 | 29 | req1 := group.Requests[0] 30 | is.Equal("POST", string(req1.Method)) 31 | is.Equal("/comments", string(req1.Path)) 32 | is.Equal(len(req1.Details), 1) 33 | is.Equal(req1.Details[0].Detail().Key, "Content-Type") 34 | is.Equal(req1.Details[0].Detail().Value.Data, "application/json") 35 | is.Equal(req1.ExpectedDetails[0].Detail().Key, "Status") 36 | is.Equal(req1.ExpectedDetails[0].Detail().Value.Data, 201) 37 | is.Equal(req1.Body.String(), `{ 38 | "name": "Mat", 39 | "comment": "Good work" 40 | }`) 41 | is.Equal(req1.BodyType, "json") 42 | is.Equal(req1.ExpectedBody.String(), `{ 43 | "id": "123", 44 | "name": "Mat", 45 | "comment": "Good work" 46 | }`) 47 | is.Equal(req1.ExpectedBodyType, "json") 48 | 49 | req2 := group.Requests[1] 50 | is.Equal("GET", req2.Method) 51 | is.Equal("/comments/{id}", req2.Path) 52 | is.Equal(len(req2.Params), 1) 53 | is.Equal(req2.Params[0].Detail().Key, "pretty") 54 | is.Equal(req2.Params[0].Detail().Value.Data, true) 55 | is.Equal(req2.ExpectedDetails[0].Detail().Key, "Status") 56 | is.Equal(req2.ExpectedDetails[0].Detail().Value.Data, 200) 57 | is.Equal(req2.ExpectedDetails[1].Detail().Key, "Content-Type") 58 | is.Equal(req2.ExpectedDetails[1].Detail().Value.Data, "application/json") 59 | is.Equal(req2.ExpectedBody.String(), `{ 60 | "id": "123", 61 | "name": "Mat", 62 | "comment": "Good work" 63 | }`) 64 | is.Equal(req2.ExpectedBody.Number(), 46) 65 | 66 | group = groups[1] 67 | is.Equal(len(group.Requests), 1) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /parse/value.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | type errValue []byte 11 | 12 | func (e errValue) Error() string { 13 | return fmt.Sprintf("invalid value: %s (did you forget quotes?)", string(e)) 14 | } 15 | 16 | func isRegex(v interface{}) bool { 17 | s, ok := v.(string) 18 | if !ok { 19 | return false 20 | } 21 | return strings.HasPrefix(s, `/`) && strings.HasSuffix(s, `/`) 22 | } 23 | 24 | // Value wraps any kind of data and provides helpers 25 | // for inspecting it. 26 | type Value struct { 27 | Data interface{} 28 | } 29 | 30 | func (v Value) String() string { 31 | if isRegex(v.Data) { 32 | return v.Data.(string) 33 | } 34 | b, err := json.Marshal(v.Data) 35 | if err != nil { 36 | panic("silk: cannot marshal value: \"" + fmt.Sprintf("%v", v.Data) + "\": " + err.Error()) 37 | } 38 | return string(b) 39 | } 40 | 41 | // Equal gets whether the Data and specified value are equal. 42 | // Supports regexp values. 43 | func (v Value) Equal(val interface{}) bool { 44 | var str string 45 | var ok bool 46 | if str, ok = v.Data.(string); !ok { 47 | return v.Data == val 48 | } 49 | if isRegex(str) { 50 | // looks like regexp to me 51 | regex := regexp.MustCompile(str[1 : len(str)-1]) 52 | // turn the value into a string 53 | valStr := fmt.Sprintf("%v", val) 54 | if regex.Match([]byte(valStr)) { 55 | return true 56 | } 57 | } 58 | return fmt.Sprintf("%v", v.Data) == fmt.Sprintf("%v", val) 59 | } 60 | 61 | // Type gets a string describing the type of this Value. 62 | func (v Value) Type() string { 63 | var str string 64 | var ok bool 65 | if str, ok = v.Data.(string); !ok { 66 | return fmt.Sprintf("%T", v.Data) 67 | } 68 | if isRegex(str) { 69 | return "regex" 70 | } 71 | return "string" 72 | } 73 | 74 | // ParseValue parses the specified bytes into a Value 75 | // using the encoding/json unmarshaller. 76 | func ParseValue(src []byte) *Value { 77 | var v interface{} 78 | src = clean(src) 79 | if err := json.Unmarshal(src, &v); err != nil { 80 | return &Value{Data: string(src)} 81 | } 82 | return &Value{Data: v} 83 | } 84 | -------------------------------------------------------------------------------- /parse/value_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/cheekybits/is" 8 | ) 9 | 10 | func TestValue(t *testing.T) { 11 | is := is.New(t) 12 | 13 | var tests = []interface{}{ 14 | "String", 15 | 123, 16 | 1.23, 17 | true, 18 | nil, 19 | } 20 | for _, test := range tests { 21 | b, err := json.Marshal(test) 22 | is.NoErr(err) 23 | actual := ParseValue(b) 24 | is.Equal(actual.Data, test) 25 | } 26 | 27 | } 28 | 29 | func TestValueEqual(t *testing.T) { 30 | is := is.New(t) 31 | 32 | v := ParseValue([]byte("something")) 33 | is.True(v.Equal("something")) 34 | is.False(v.Equal("else")) 35 | is.Equal("string", v.Type()) 36 | 37 | v = ParseValue([]byte("123")) 38 | is.Equal("float64", v.Type()) 39 | 40 | v = ParseValue([]byte("/^2.{2}$/")) 41 | is.True(v.Equal(200)) 42 | is.True(v.Equal(201)) 43 | is.False(v.Equal(404)) 44 | is.Equal("regex", v.Type()) 45 | 46 | v = ParseValue([]byte("/application/json/")) 47 | is.True(v.Equal("application/json")) 48 | is.True(v.Equal("application/json; charset=utf-8")) 49 | is.True(v.Equal("text/xml; application/json; charset=utf-8")) 50 | is.False(v.Equal("text/xml; charset=utf-8")) 51 | is.Equal("regex", v.Type()) 52 | is.Equal(`/application/json/`, v.String()) 53 | 54 | v = ParseValue([]byte("/Silk/")) 55 | is.True(v.Equal("My name is Silk.")) 56 | is.True(v.Equal("Silk is my name.")) 57 | is.False(v.Equal("I don't contain that word!")) 58 | } 59 | -------------------------------------------------------------------------------- /runner/body.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | // ParseJSONBody parses a JSON body. 9 | func ParseJSONBody(r io.Reader) (interface{}, error) { 10 | var v interface{} 11 | if err := json.NewDecoder(r).Decode(&v); err != nil { 12 | return nil, err 13 | } 14 | return v, nil 15 | } 16 | -------------------------------------------------------------------------------- /runner/doc.go: -------------------------------------------------------------------------------- 1 | // Package runner provides tools for running Silk tests. 2 | package runner 3 | -------------------------------------------------------------------------------- /runner/run.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "reflect" 12 | "strconv" 13 | "strings" 14 | "sync" 15 | "testing" 16 | 17 | "github.com/matryer/m" 18 | "github.com/matryer/silk/parse" 19 | ) 20 | 21 | const indent = " " 22 | 23 | // T represents types to which failures may be reported. 24 | // The testing.T type is one such example. 25 | type T interface { 26 | FailNow() 27 | Log(...interface{}) 28 | } 29 | 30 | // Runner runs parsed tests. 31 | type Runner struct { 32 | t T 33 | rootURL string 34 | vars map[string]*parse.Value 35 | // DoRequest makes the request and returns the response. 36 | // By default uses http.DefaultClient.Do. 37 | DoRequest func(r *http.Request) (*http.Response, error) 38 | // ParseBody is the function to use to attempt to parse 39 | // response bodies to make data available for assertions. 40 | ParseBody func(r io.Reader) (interface{}, error) 41 | // Log is the function to log to. 42 | Log func(string) 43 | // Verbose is the function that logs verbose debug information. 44 | Verbose func(...interface{}) 45 | // NewRequest makes a new http.Request. By default, uses http.NewRequest. 46 | NewRequest func(method, urlStr string, body io.Reader) (*http.Request, error) 47 | } 48 | 49 | // New makes a new Runner with the given testing T target and the 50 | // root URL. 51 | func New(t T, URL string) *Runner { 52 | r := &Runner{ 53 | t: t, 54 | rootURL: URL, 55 | vars: make(map[string]*parse.Value), 56 | DoRequest: http.DefaultTransport.RoundTrip, 57 | Log: func(s string) { 58 | fmt.Println(s) 59 | }, 60 | Verbose: func(args ...interface{}) { 61 | if !testing.Verbose() { 62 | return 63 | } 64 | fmt.Println(args...) 65 | }, 66 | ParseBody: ParseJSONBody, 67 | NewRequest: http.NewRequest, 68 | } 69 | // capture environment variables by default 70 | for _, e := range os.Environ() { 71 | pair := strings.Split(e, "=") 72 | r.vars[pair[0]] = parse.ParseValue([]byte(pair[1])) 73 | } 74 | return r 75 | } 76 | 77 | func (r *Runner) log(args ...interface{}) { 78 | var strs []string 79 | for _, arg := range args { 80 | strs = append(strs, fmt.Sprint(arg)) 81 | } 82 | strs = append(strs, " ") 83 | r.Log(strings.Join(strs, " ")) 84 | } 85 | 86 | // RunGlob is a helper that runs the files returned by filepath.Glob. 87 | // runner.RunGlob(filepath.Glob("pattern")) 88 | func (r *Runner) RunGlob(files []string, err error) { 89 | if err != nil { 90 | r.t.Log("silk:", err) 91 | r.t.FailNow() 92 | return 93 | } 94 | r.RunFile(files...) 95 | } 96 | 97 | // RunFile parses and runs the specified file(s). 98 | func (r *Runner) RunFile(filenames ...string) { 99 | groups, err := parse.ParseFile(filenames...) 100 | if err != nil { 101 | r.log(err) 102 | return 103 | } 104 | r.RunGroup(groups...) 105 | } 106 | 107 | // RunGroup runs a parse.Group. 108 | // Consider RunFile instead. 109 | func (r *Runner) RunGroup(groups ...*parse.Group) { 110 | for _, group := range groups { 111 | r.runGroup(group) 112 | } 113 | } 114 | 115 | func (r *Runner) runGroup(group *parse.Group) { 116 | for _, req := range group.Requests { 117 | r.runRequest(group, req) 118 | } 119 | } 120 | 121 | func (r *Runner) runRequest(group *parse.Group, req *parse.Request) { 122 | m := string(req.Method) 123 | p := string(req.Path) 124 | absPath := r.resolveVars(r.rootURL + p) 125 | m = r.resolveVars(m) 126 | r.Verbose(string(req.Method), absPath) 127 | var body io.Reader 128 | var bodyStr string 129 | if len(req.Body) > 0 { 130 | bodyStr = r.resolveVars(req.Body.String()) 131 | body = strings.NewReader(bodyStr) 132 | } 133 | // make request 134 | httpReq, err := r.NewRequest(m, absPath, body) 135 | if err != nil { 136 | r.log("invalid request: ", err) 137 | r.t.FailNow() 138 | return 139 | } 140 | // set body 141 | bodyLen := len(bodyStr) 142 | if bodyLen > 0 { 143 | httpReq.ContentLength = int64(bodyLen) 144 | } 145 | // set request headers 146 | for _, line := range req.Details { 147 | detail := line.Detail() 148 | val := fmt.Sprintf("%v", detail.Value.Data) 149 | val = r.resolveVars(val) 150 | detail.Value = parse.ParseValue([]byte(val)) 151 | r.Verbose(indent, detail.String()) 152 | httpReq.Header.Add(detail.Key, val) 153 | } 154 | // set parameters 155 | q := httpReq.URL.Query() 156 | for _, line := range req.Params { 157 | detail := line.Detail() 158 | val := fmt.Sprintf("%v", detail.Value.Data) 159 | val = r.resolveVars(val) 160 | detail.Value = parse.ParseValue([]byte(val)) 161 | r.Verbose(indent, detail.String()) 162 | q.Add(detail.Key, val) 163 | } 164 | httpReq.URL.RawQuery = q.Encode() 165 | 166 | // print request body 167 | if bodyLen > 0 { 168 | r.Verbose("```") 169 | r.Verbose(bodyStr) 170 | r.Verbose("```") 171 | } 172 | // perform request 173 | httpRes, err := r.DoRequest(httpReq) 174 | if err != nil { 175 | r.log(err) 176 | r.t.FailNow() 177 | return 178 | } 179 | 180 | // collect response details 181 | responseDetails := make(map[string]interface{}) 182 | for k, vs := range httpRes.Header { 183 | for _, v := range vs { 184 | responseDetails[k] = v 185 | } 186 | } 187 | // add cookies to repsonse details 188 | var cookieStrs []string 189 | for _, cookie := range httpRes.Cookies() { 190 | cookieStrs = append(cookieStrs, cookie.String()) 191 | } 192 | responseDetails["Set-Cookie"] = strings.Join(cookieStrs, "|") 193 | 194 | // set other details 195 | responseDetails["Status"] = float64(httpRes.StatusCode) 196 | 197 | actualBody, err := ioutil.ReadAll(httpRes.Body) 198 | if err != nil { 199 | r.log("failed to read body: ", err) 200 | r.t.FailNow() 201 | return 202 | } 203 | if len(actualBody) > 0 { 204 | r.Verbose("```") 205 | r.Verbose(string(actualBody)) 206 | r.Verbose("```") 207 | } 208 | 209 | // set the body as a field (see issue #15) 210 | responseDetails["Body"] = string(actualBody) 211 | 212 | /* 213 | Assertions 214 | --------------------------------------------------------- 215 | */ 216 | 217 | // assert the body 218 | if len(req.ExpectedBody) > 0 { 219 | // check body against expected body 220 | exp := r.resolveVars(req.ExpectedBody.String()) 221 | 222 | // depending on the expectedBodyType: 223 | // json*: check if expectedBody as JSON is a subset of the actualBody as json 224 | // json(exact): check JSON for deep equality (avoids checking diffs in white space and order) 225 | // *: check string for verbatim equality 226 | 227 | expectedTypeIsJSON := strings.HasPrefix(req.ExpectedBodyType, "json") 228 | if expectedTypeIsJSON { 229 | // decode json from string 230 | var expectedJSON interface{} 231 | var actualJSON interface{} 232 | json.Unmarshal([]byte(exp), &expectedJSON) 233 | json.Unmarshal(actualBody, &actualJSON) 234 | 235 | if !strings.Contains(req.ExpectedBodyType, "exact") { 236 | eq, err := r.assertJSONIsEqualOrSubset(expectedJSON, actualJSON) 237 | if !eq { 238 | r.fail(group, req, req.ExpectedBody.Number(), "- body doesn't match", err) 239 | return 240 | } 241 | } else if !reflect.DeepEqual(actualJSON, expectedJSON) { 242 | r.fail(group, req, req.ExpectedBody.Number(), "- body doesn't match") 243 | return 244 | } 245 | } else if !r.assertBody(actualBody, []byte(exp)) { 246 | r.fail(group, req, req.ExpectedBody.Number(), "- body doesn't match") 247 | return 248 | } 249 | } 250 | 251 | // assert the details 252 | var parseDataOnce sync.Once 253 | var data interface{} 254 | var errData error 255 | if len(req.ExpectedDetails) > 0 { 256 | for _, line := range req.ExpectedDetails { 257 | detail := line.Detail() 258 | // resolve any variables mentioned in this detail value 259 | if detail.Value.Type() == "string" { 260 | detail.Value.Data = r.resolveVars(detail.Value.Data.(string)) 261 | } 262 | if strings.HasPrefix(detail.Key, "Data") { 263 | parseDataOnce.Do(func() { 264 | data, errData = r.ParseBody(bytes.NewReader(actualBody)) 265 | }) 266 | if !r.assertData(line, data, errData, detail.Key, detail.Value) { 267 | r.fail(group, req, line.Number, "- "+detail.Key+" doesn't match") 268 | return 269 | } 270 | continue 271 | } 272 | var actual interface{} 273 | var present bool 274 | if actual, present = responseDetails[detail.Key]; !present { 275 | r.log(detail.Key, fmt.Sprintf("expected %s: %s actual %T: %s", detail.Value.Type(), detail.Value, actual, "(missing)")) 276 | r.fail(group, req, line.Number, "- "+detail.Key+" doesn't match") 277 | return 278 | } 279 | if !r.assertDetail(line, detail.Key, actual, detail.Value) { 280 | r.fail(group, req, line.Number, "- "+detail.Key+" doesn't match") 281 | return 282 | } 283 | } 284 | } 285 | 286 | } 287 | 288 | func (r *Runner) resolveVars(s string) string { 289 | for k, v := range r.vars { 290 | match := "{" + k + "}" 291 | s = strings.Replace(s, match, fmt.Sprintf("%v", v.Data), -1) 292 | } 293 | return s 294 | } 295 | 296 | func (r *Runner) fail(group *parse.Group, req *parse.Request, line int, args ...interface{}) { 297 | logargs := []interface{}{"--- FAIL:", string(req.Method), string(req.Path), "\n", group.Filename + ":" + strconv.FormatInt(int64(line), 10)} 298 | r.log(append(logargs, args...)...) 299 | r.t.FailNow() 300 | } 301 | 302 | func (r *Runner) assertBody(actual, expected []byte) bool { 303 | if !reflect.DeepEqual(actual, expected) { 304 | r.log("body expected:") 305 | r.log("```") 306 | r.log(string(expected)) 307 | r.log("```") 308 | r.Log("") 309 | r.log("actual:") 310 | r.log("```") 311 | r.log(string(actual)) 312 | r.log("```") 313 | return false 314 | } 315 | return true 316 | } 317 | 318 | func (r *Runner) assertDetail(line *parse.Line, key string, actual interface{}, expected *parse.Value) bool { 319 | if !expected.Equal(actual) { 320 | actualVal := parse.ParseValue([]byte(fmt.Sprintf("%v", actual))) 321 | actualString := actualVal.String() 322 | if v, ok := actual.(string); ok { 323 | actualString = fmt.Sprintf(`"%s"`, v) 324 | } 325 | 326 | if expected.Type() == actualVal.Type() { 327 | r.log(key, fmt.Sprintf("expected: %s actual: %s", expected, actualString)) 328 | } else { 329 | r.log(key, fmt.Sprintf("expected %s: %s actual %T: %s", expected.Type(), expected, actual, actualString)) 330 | } 331 | 332 | return false 333 | } 334 | // capture any vars (// e.g. {placeholder}) 335 | if capture := line.Capture(); len(capture) > 0 { 336 | r.capture(capture, actual) 337 | } 338 | return true 339 | } 340 | 341 | func (r *Runner) assertData(line *parse.Line, data interface{}, errData error, key string, expected *parse.Value) bool { 342 | if errData != nil { 343 | r.log(key, fmt.Sprintf("expected %s: %s actual: failed to parse body: %s", expected.Type(), expected, errData)) 344 | return false 345 | } 346 | if data == nil { 347 | r.log(key, fmt.Sprintf("expected %s: %s actual: no data", expected.Type(), expected)) 348 | return false 349 | } 350 | actual, ok := m.GetOK(map[string]interface{}{"Data": data}, key) 351 | if !ok && expected.Data != nil { 352 | r.log(key, fmt.Sprintf("expected %s: %s actual: (missing)", expected.Type(), expected)) 353 | return false 354 | } 355 | // capture any vars (// e.g. {placeholder}) 356 | if capture := line.Capture(); len(capture) > 0 { 357 | r.capture(capture, actual) 358 | } 359 | if !ok && expected.Data == nil { 360 | return true 361 | } 362 | if !expected.Equal(actual) { 363 | actualVal := parse.ParseValue([]byte(fmt.Sprintf("%v", actual))) 364 | actualString := actualVal.String() 365 | if v, ok := actual.(string); ok { 366 | actualString = fmt.Sprintf(`"%s"`, v) 367 | } 368 | if expected.Type() == actualVal.Type() { 369 | r.log(key, fmt.Sprintf("expected: %s actual: %s", expected, actualString)) 370 | } else { 371 | r.log(key, fmt.Sprintf("expected %s: %s actual %T: %s", expected.Type(), expected, actual, actualString)) 372 | } 373 | return false 374 | } 375 | return true 376 | } 377 | 378 | // assertJSONIsEqualOrSubset returns true if v1 and v2 are equal in value 379 | // or if both are maps (of type map[string]interface{}) and v1 is a subset of v2, where 380 | // all keys that are present in v1 are present with the same value in v2. 381 | func (r *Runner) assertJSONIsEqualOrSubset(v1 interface{}, v2 interface{}) (bool, error) { 382 | if (v1 == nil) && (v2 == nil) { 383 | return true, nil 384 | } 385 | 386 | // check if both are non nil and that type matches 387 | if ((v1 == nil) != (v2 == nil)) || 388 | (reflect.ValueOf(v1).Type() != reflect.ValueOf(v2).Type()) { 389 | return false, fmt.Errorf("types do not match") 390 | } 391 | 392 | switch v1.(type) { 393 | case map[string]interface{}: 394 | // recursively check maps 395 | // v2 is of same type as v1 as check in early return 396 | v2map := v2.(map[string]interface{}) 397 | for objK, objV := range v1.(map[string]interface{}) { 398 | if v2map[objK] == nil { 399 | return false, fmt.Errorf("missing key '%s'", objK) 400 | } 401 | equalForKey, errForKey := r.assertJSONIsEqualOrSubset(objV, v2map[objK]) 402 | if !equalForKey { 403 | return false, fmt.Errorf("mismatch for key '%s': %s", objK, errForKey) 404 | } 405 | } 406 | 407 | return true, nil 408 | default: 409 | // all non-map types must be deep equal 410 | if !reflect.DeepEqual(v1, v2) { 411 | return false, fmt.Errorf("values do not match - %s != %s", v1, v2) 412 | } 413 | return true, nil 414 | } 415 | } 416 | 417 | func (r *Runner) capture(key string, val interface{}) { 418 | r.vars[key] = &parse.Value{Data: val} 419 | r.Verbose("captured", key, "=", val) 420 | } 421 | -------------------------------------------------------------------------------- /runner/run_test.go: -------------------------------------------------------------------------------- 1 | package runner_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/cheekybits/is" 12 | "github.com/matryer/silk/parse" 13 | "github.com/matryer/silk/runner" 14 | "github.com/matryer/silk/testutil" 15 | ) 16 | 17 | func TestTInter(t *testing.T) { 18 | var tt runner.T 19 | tt = &testing.T{} 20 | _ = tt 21 | } 22 | 23 | func TestRunGroupSuccess(t *testing.T) { 24 | is := is.New(t) 25 | subT := &testT{} 26 | s := httptest.NewServer(testutil.EchoHandler()) 27 | defer s.Close() 28 | r := runner.New(subT, s.URL) 29 | g, err := parse.ParseFile("../testfiles/success/echo.success.silk.md") 30 | is.NoErr(err) 31 | r.RunGroup(g...) 32 | is.False(subT.Failed()) 33 | } 34 | 35 | func TestRunFileSuccess(t *testing.T) { 36 | is := is.New(t) 37 | subT := &testT{} 38 | s := httptest.NewServer(testutil.EchoHandler()) 39 | defer s.Close() 40 | r := runner.New(subT, s.URL) 41 | r.RunFile("../testfiles/success/echo.success.silk.md") 42 | is.False(subT.Failed()) 43 | } 44 | 45 | // https://github.com/matryer/silk/issues/31 46 | func TestIssue31(t *testing.T) { 47 | is := is.New(t) 48 | subT := &testT{} 49 | s := httptest.NewServer(testutil.EchoHandler()) 50 | defer s.Close() 51 | r := runner.New(subT, s.URL) 52 | r.RunFile("../testfiles/success/issue-31.silk.md") 53 | is.False(subT.Failed()) 54 | } 55 | 56 | // https://github.com/matryer/silk/issues/2 57 | func TestCapturedVars(t *testing.T) { 58 | is := is.New(t) 59 | subT := &testT{} 60 | s := httptest.NewServer(testutil.EchoDataHandler()) 61 | defer s.Close() 62 | os.Setenv("$EnvStatus", "awesome") 63 | os.Setenv("$AppNameFromEnv", "Silk") 64 | r := runner.New(subT, s.URL) 65 | r.RunFile("../testfiles/success/captured-vars.silk.md") 66 | is.False(subT.Failed()) 67 | } 68 | 69 | // https://github.com/matryer/silk/issues/37 70 | func TestStandardSeparator(t *testing.T) { 71 | is := is.New(t) 72 | subT := &testT{} 73 | s := httptest.NewServer(testutil.EchoHandler()) 74 | defer s.Close() 75 | os.Setenv("$AppNameFromEnv", "Silk") 76 | r := runner.New(subT, s.URL) 77 | r.RunFile("../testfiles/success/issue-37.silk.md") 78 | is.False(subT.Failed()) 79 | } 80 | 81 | // https://github.com/matryer/silk/issues/28 82 | func TestFailureNonTrimmedExpection(t *testing.T) { 83 | is := is.New(t) 84 | subT := &testT{} 85 | s := httptest.NewServer(testutil.EchoDataHandler()) 86 | defer s.Close() 87 | r := runner.New(subT, s.URL) 88 | var logs []string 89 | r.Log = func(s string) { 90 | logs = append(logs, s) 91 | } 92 | g, err := parse.ParseFile("../testfiles/failure/echo.failure.nontrimmedexpectation.silk.md") 93 | is.NoErr(err) 94 | r.RunGroup(g...) 95 | is.True(subT.Failed()) 96 | logstr := strings.Join(logs, "\n") 97 | 98 | is.True(strings.Contains(logstr, `Data.body.status expected: "awesome" actual: " awesome"`)) 99 | is.True(strings.Contains(logstr, "--- FAIL: GET /echo")) 100 | is.True(strings.Contains(logstr, "../testfiles/failure/echo.failure.nontrimmedexpectation.silk.md:18 - Data.body.status doesn't match")) 101 | } 102 | 103 | func TestData(t *testing.T) { 104 | is := is.New(t) 105 | subT := &testT{} 106 | s := httptest.NewServer(testutil.EchoDataHandler()) 107 | defer s.Close() 108 | r := runner.New(subT, s.URL) 109 | r.RunFile("../testfiles/success/data.silk.md") 110 | is.False(subT.Failed()) 111 | } 112 | 113 | func TestBodyField(t *testing.T) { 114 | is := is.New(t) 115 | subT := &testT{} 116 | s := httptest.NewServer(testutil.EchoDataHandler()) 117 | defer s.Close() 118 | r := runner.New(subT, s.URL) 119 | r.RunFile("../testfiles/success/body-as-field.silk.md") 120 | is.False(subT.Failed()) 121 | } 122 | 123 | func TestRunFileSuccessNoBody(t *testing.T) { 124 | is := is.New(t) 125 | subT := &testT{} 126 | s := httptest.NewServer(testutil.EchoHandler()) 127 | defer s.Close() 128 | r := runner.New(subT, s.URL) 129 | r.RunFile("../testfiles/success/echo.nobody.success.silk.md") 130 | is.False(subT.Failed()) 131 | } 132 | 133 | func TestFailureWrongBody(t *testing.T) { 134 | is := is.New(t) 135 | subT := &testT{} 136 | s := httptest.NewServer(testutil.EchoHandler()) 137 | defer s.Close() 138 | r := runner.New(subT, s.URL) 139 | var logs []string 140 | r.Log = func(s string) { 141 | logs = append(logs, s) 142 | } 143 | g, err := parse.ParseFile("../testfiles/failure/echo.failure.wrongbody.silk.md") 144 | is.NoErr(err) 145 | r.RunGroup(g...) 146 | is.True(subT.Failed()) 147 | logstr := strings.Join(logs, "\n") 148 | is.True(strings.Contains(logstr, "body expected:")) 149 | is.True(strings.Contains(logstr, "GET /echo")) 150 | is.True(strings.Contains(logstr, "Hello silky.")) 151 | is.True(strings.Contains(logstr, "actual:")) 152 | is.True(strings.Contains(logstr, "GET /echo")) 153 | is.True(strings.Contains(logstr, "Hello silk.")) 154 | is.True(strings.Contains(logstr, "--- FAIL: GET /echo")) 155 | is.True(strings.Contains(logstr, "../testfiles/failure/echo.failure.wrongbody.silk.md:14 - body doesn't match")) 156 | } 157 | 158 | func TestFailureWrongHeader(t *testing.T) { 159 | is := is.New(t) 160 | subT := &testT{} 161 | s := httptest.NewServer(testutil.EchoHandler()) 162 | defer s.Close() 163 | r := runner.New(subT, s.URL) 164 | var logs []string 165 | r.Log = func(s string) { 166 | logs = append(logs, s) 167 | } 168 | g, err := parse.ParseFile("../testfiles/failure/echo.failure.wrongheader.silk.md") 169 | is.NoErr(err) 170 | r.RunGroup(g...) 171 | is.True(subT.Failed()) 172 | logstr := strings.Join(logs, "\n") 173 | 174 | is.True(strings.Contains(logstr, `Content-Type expected: "wrong/type" actual: "text/plain; charset=utf-8"`)) 175 | is.True(strings.Contains(logstr, "--- FAIL: GET /echo")) 176 | is.True(strings.Contains(logstr, "../testfiles/failure/echo.failure.wrongheader.silk.md:22 - Content-Type doesn't match")) 177 | } 178 | 179 | func TestGlob(t *testing.T) { 180 | is := is.New(t) 181 | subT := &testT{} 182 | s := httptest.NewServer(testutil.EchoHandler()) 183 | defer s.Close() 184 | r := runner.New(subT, s.URL) 185 | r.Log = func(s string) {} // don't bother logging 186 | r.RunGlob(filepath.Glob("../testfiles/failure/echo.*.silk.md")) 187 | is.True(subT.Failed()) 188 | } 189 | 190 | func TestCookies(t *testing.T) { 191 | is := is.New(t) 192 | subT := &testT{} 193 | s := httptest.NewServer(testutil.EchoHandler()) 194 | defer s.Close() 195 | r := runner.New(subT, s.URL) 196 | r.RunFile("../testfiles/success/cookies.silk.md") 197 | is.False(subT.Failed()) 198 | } 199 | 200 | func TestFailureFieldsSameType(t *testing.T) { 201 | is := is.New(t) 202 | subT := &testT{} 203 | s := httptest.NewServer(testutil.EchoHandler()) 204 | defer s.Close() 205 | r := runner.New(subT, s.URL) 206 | var logs []string 207 | r.Log = func(s string) { 208 | logs = append(logs, s) 209 | } 210 | g, err := parse.ParseFile("../testfiles/failure/echo.failure.fieldssametype.silk.md") 211 | is.NoErr(err) 212 | r.RunGroup(g...) 213 | is.True(subT.Failed()) 214 | logstr := strings.Join(logs, "\n") 215 | 216 | is.True(strings.Contains(logstr, "Status expected: 400 actual: 200")) 217 | } 218 | 219 | func TestFailureFieldsDifferentTypes(t *testing.T) { 220 | is := is.New(t) 221 | subT := &testT{} 222 | s := httptest.NewServer(testutil.EchoHandler()) 223 | defer s.Close() 224 | r := runner.New(subT, s.URL) 225 | var logs []string 226 | r.Log = func(s string) { 227 | logs = append(logs, s) 228 | } 229 | g, err := parse.ParseFile("../testfiles/failure/echo.failure.fieldsdifferenttypes.silk.md") 230 | is.NoErr(err) 231 | r.RunGroup(g...) 232 | is.True(subT.Failed()) 233 | logstr := strings.Join(logs, "\n") 234 | 235 | is.True(strings.Contains(logstr, `Status expected string: "400" actual float64: 200`)) 236 | } 237 | 238 | func TestRunJsonModesSuccess(t *testing.T) { 239 | is := is.New(t) 240 | subT := &testT{} 241 | s := httptest.NewServer(testutil.EchoRawHandler()) 242 | defer s.Close() 243 | r := runner.New(subT, s.URL) 244 | g, err := parse.ParseFile("../testfiles/success/echoraw.success.jsonmodes.silk.md") 245 | is.NoErr(err) 246 | r.RunGroup(g...) 247 | is.False(subT.Failed()) 248 | } 249 | 250 | func TestRunJsonModesFailure(t *testing.T) { 251 | is := is.New(t) 252 | subT := &testT{} 253 | s := httptest.NewServer(testutil.EchoRawHandler()) 254 | defer s.Close() 255 | r := runner.New(subT, s.URL) 256 | g, err := parse.ParseFile("../testfiles/failure/echoraw.failure.jsonmodes.silk.md") 257 | is.NoErr(err) 258 | r.RunGroup(g...) 259 | is.True(subT.Failed()) 260 | } 261 | 262 | type testT struct { 263 | log []string 264 | failed bool 265 | } 266 | 267 | func (t *testT) FailNow() { 268 | t.failed = true 269 | } 270 | 271 | func (t *testT) Failed() bool { 272 | return t.failed 273 | } 274 | 275 | func (t *testT) LogString() string { 276 | return strings.Join(t.log, "\n") 277 | } 278 | 279 | func (t *testT) Log(args ...interface{}) { 280 | t.log = append(t.log, fmt.Sprint(args...)) 281 | } 282 | -------------------------------------------------------------------------------- /testfiles/failure/echo.failure.fieldsdifferenttypes.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | === 6 | 7 | * Status: "400" -------------------------------------------------------------------------------- /testfiles/failure/echo.failure.fieldssametype.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | === 6 | 7 | * Status: 400 -------------------------------------------------------------------------------- /testfiles/failure/echo.failure.nontrimmedexpectation.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * `Content-Type`: `"application/json"` 6 | 7 | ``` 8 | {"name":"Silk","status":" awesome"} 9 | ``` 10 | 11 | === 12 | 13 | ### Response 14 | 15 | * `Server`: `"EchoDataHandler"` 16 | * `Status`: `200` 17 | * `Data.body.name`: `"Silk"` 18 | * `Data.body.status`: `"awesome"` 19 | -------------------------------------------------------------------------------- /testfiles/failure/echo.failure.wrongbody.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * Content-Type: text/plain 6 | 7 | ``` 8 | Hello silk. 9 | ``` 10 | 11 | === 12 | 13 | ``` 14 | GET /echo 15 | * Accept-Encoding: gzip 16 | * Content-Type: text/plain 17 | * User-Agent: Go-http-client/1.1 18 | Hello silky. 19 | ``` -------------------------------------------------------------------------------- /testfiles/failure/echo.failure.wrongheader.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * Content-Type: "text/plain" 6 | 7 | ``` 8 | Hello silk. 9 | ``` 10 | 11 | === 12 | 13 | ``` 14 | GET /echo 15 | * Accept-Encoding: "gzip" 16 | * Content-Length: "11" 17 | * Content-Type: "text/plain" 18 | * User-Agent: "Go-http-client/1.1" 19 | Hello silk. 20 | ``` 21 | 22 | * Content-Type: "wrong/type" 23 | -------------------------------------------------------------------------------- /testfiles/failure/echoraw.failure.jsonmodes.silk.md: -------------------------------------------------------------------------------- 1 | # Comments and things 2 | 3 | * Root: "http://localhost:8080/" 4 | 5 | The server echos the request's body directly. 6 | 7 | ## `GET /json` 8 | 9 | ### Example request 10 | 11 | ```json 12 | { "name": "Mat", "comment": "Good work", "meta" : { "api" : 1.0 } } 13 | ``` 14 | 15 | === 16 | 17 | ### Example response 18 | 19 | * `Status`: `200` 20 | 21 | By defaul using the `json` qualifier in your expected request body only checks for 22 | a subset of the response. This allows you to scope your tests or to be more lenient 23 | towards future unrelated changes. Additional fields in the response do not invalidate 24 | the test. 25 | 26 | ```json 27 | { 28 | "name": "Mat", 29 | "meta" : { "client" : "tester" } 30 | } 31 | ``` 32 | 33 | ## `GET /json/same` 34 | 35 | ### Example request 36 | 37 | ```json 38 | { "name": "Mat", "meta" : { "client" : "tester" } } 39 | ``` 40 | 41 | === 42 | 43 | ### Example response 44 | 45 | * `Status`: `200` 46 | 47 | Use the `json(strict)` qualifier in your expected request body to ensure that the json object are the same. 48 | This allows differences in order and white space. 49 | 50 | ```json(strict) 51 | { 52 | "comment": "Good work", 53 | "name": "Mat", 54 | "meta" : { 55 | "client" : "tester" 56 | } 57 | } 58 | ``` 59 | -------------------------------------------------------------------------------- /testfiles/success/body-as-field.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * `Content-Type`: `"application/json"` 6 | * `X-Another-Header`: `"value"` 7 | 8 | ``` 9 | {"name":"Silk","status":"awesome","a_bool":true,"nothing":null,"release_year":2016} 10 | ``` 11 | 12 | === 13 | 14 | ### Response 15 | 16 | * `Server`: `"EchoDataHandler"` 17 | * `Status`: `200` 18 | * `Body`: /Silk/ 19 | -------------------------------------------------------------------------------- /testfiles/success/captured-vars.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * `Content-Type`: `"application/json"` 6 | * `X-Another-Header`: `"value"` 7 | 8 | ``` 9 | {"name":"{$AppNameFromEnv}","status":"awesome","a_bool":true,"nothing":null,"release_year":2016,"exp":200} 10 | ``` 11 | 12 | === 13 | 14 | ### Response 15 | 16 | * `Server`: `"EchoDataHandler"` 17 | * `Status`: `200` // Expected {value} 18 | * `Data.body.name`: `"Silk"` 19 | * `Data.body.status`: /awesome/ // The {status} of Silk. 20 | * `Data.body.a_bool`: `true` 21 | * `Data.body.nothing`: `null` 22 | * `Data.body.release_year`: `2016` 23 | * `Data.body.exp`: `200` 24 | 25 | ## POST /echo/{status} 26 | 27 | * ?status={status} 28 | * X-Status: {status} 29 | 30 | ``` 31 | {"st":"{status}"} 32 | ``` 33 | 34 | === 35 | 36 | ### Response 37 | 38 | * `Server`: `"EchoDataHandler"` 39 | * `Status`: `{value}` 40 | * `Data.body.st`: `"awesome"` 41 | * `Data.body.st`: {status} 42 | 43 | ``` 44 | {"Accept-Encoding":"gzip","Content-Length":"16","User-Agent":"Go-http-client/1.1","X-Status":"awesome","body":{"st":"awesome"},"bodystr":"{\"st\":\"awesome\"}","method":"POST","path":"/echo/awesome","status":["awesome"]} 45 | 46 | ``` 47 | 48 | ## POST /echo/{$EnvStatus} 49 | 50 | * ?status={$EnvStatus} 51 | * X-Status: {$EnvStatus} 52 | 53 | ``` 54 | {"st":"{$EnvStatus}"} 55 | ``` 56 | 57 | === 58 | 59 | ### Response 60 | 61 | * `Server`: `"EchoDataHandler"` 62 | * `Status`: `{value}` 63 | * `Data.body.st`: `"awesome"` 64 | * `Data.body.st`: {$EnvStatus} 65 | 66 | ``` 67 | {"Accept-Encoding":"gzip","Content-Length":"16","User-Agent":"Go-http-client/1.1","X-Status":"awesome","body":{"st":"awesome"},"bodystr":"{\"st\":\"awesome\"}","method":"POST","path":"/echo/awesome","status":["awesome"]} 68 | 69 | ``` -------------------------------------------------------------------------------- /testfiles/success/comments.silk.md: -------------------------------------------------------------------------------- 1 | # Comments and things 2 | 3 | * Root: "http://localhost:8080/" 4 | 5 | ## `POST /comments` 6 | 7 | Create a comment. 8 | 9 | ### Example request 10 | 11 | ```json 12 | { 13 | "name": "Mat", 14 | "comment": "Good work" 15 | } 16 | ``` 17 | 18 | * `Content-Type`: "application/json" // ensure correct content type is specified 19 | 20 | === 21 | 22 | ### Example response 23 | 24 | * `Status`: `201` 25 | 26 | ```json 27 | { 28 | "id": "123", 29 | "name": "Mat", 30 | "comment": "Good work" 31 | } 32 | ``` 33 | 34 | ## `GET` `/comments/{id}` 35 | 36 | Read a single comment with the specified `{id}`. 37 | 38 | * `?pretty=true` // get pretty output 39 | 40 | === 41 | 42 | * `Status`: `200` 43 | * `Content-Type`: `"application/json"` 44 | 45 | ``` 46 | { 47 | "id": "123", 48 | "name": "Mat", 49 | "comment": "Good work" 50 | } 51 | ``` 52 | 53 | # Another group 54 | 55 | ## DELETE /something/1 56 | 57 | === 58 | 59 | * `Status`: `200` // OK 60 | -------------------------------------------------------------------------------- /testfiles/success/comments2.silk.md: -------------------------------------------------------------------------------- 1 | 2 | # Group three 3 | 4 | ## PATCH /something/1 5 | 6 | === 7 | 8 | * Status: 200 9 | -------------------------------------------------------------------------------- /testfiles/success/cookies.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## `GET /echo` 4 | 5 | * `Content-Type`: `"text/plain"` 6 | * `X-Another-Header`: `"value"` 7 | * `?param1=value1` 8 | * `?param2=value2` 9 | * `?param3=value3` 10 | * Cookie: name=silk; another=true 11 | 12 | ``` 13 | Hello silk. 14 | ``` 15 | 16 | === 17 | 18 | ``` 19 | GET /echo 20 | * ?param1=value1 21 | * ?param2=value2 22 | * ?param3=value3 23 | * Accept-Encoding: "gzip" 24 | * Content-Length: "11" 25 | * Content-Type: "text/plain" 26 | * Cookie: "name=silk; another=true" 27 | * User-Agent: "Go-http-client/1.1" 28 | * X-Another-Header: "value" 29 | * Cookie: another=true 30 | * Cookie: name=silk 31 | Hello silk. 32 | ``` 33 | 34 | * Content-Type: "text/plain; charset=utf-8" 35 | * Server: "EchoHandler" 36 | * Status: 200 37 | * Content-Length: "292" 38 | * Set-Cookie: /another=true/ 39 | * Set-Cookie: /name=silk/ 40 | -------------------------------------------------------------------------------- /testfiles/success/data.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * `Content-Type`: `"application/json"` 6 | * `X-Another-Header`: `"value"` 7 | 8 | ``` 9 | {"name":"Silk","status":"awesome","a_bool":true,"nothing":null,"release_year":2016} 10 | ``` 11 | 12 | === 13 | 14 | ### Response 15 | 16 | * `Server`: `"EchoDataHandler"` 17 | * `Status`: `200` 18 | * `Data.body.name`: `"Silk"` 19 | * `Data.body.status`: `"awesome"` 20 | * `Data.body.a_bool`: `true` 21 | * `Data.body.nothing`: `null` 22 | * `Data.body.release_year`: `2016` 23 | -------------------------------------------------------------------------------- /testfiles/success/echo.nobody.success.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## GET /echo 4 | 5 | * Content-Type: "text/plain" 6 | * X-Another-Header: "value" 7 | 8 | === 9 | 10 | ``` 11 | GET /echo 12 | * Accept-Encoding: "gzip" 13 | * Content-Length: "0" 14 | * Content-Type: "text/plain" 15 | * User-Agent: "Go-http-client/1.1" 16 | * X-Another-Header: "value" 17 | 18 | ``` 19 | 20 | * Content-Type: "text/plain; charset=utf-8" 21 | * Server: "EchoHandler" 22 | * Status: 200 23 | * Content-Length: "150" 24 | -------------------------------------------------------------------------------- /testfiles/success/echo.success.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## `GET /echo` 4 | 5 | * `Content-Type`: `"text/plain"` 6 | * `X-Another-Header`: `"value"` 7 | * `?param1=value1` 8 | * `?param2=value2` 9 | * `?param3=value3` 10 | 11 | ``` 12 | Hello silk. 13 | ``` 14 | 15 | === 16 | 17 | ``` 18 | GET /echo 19 | * ?param1=value1 20 | * ?param2=value2 21 | * ?param3=value3 22 | * Accept-Encoding: "gzip" 23 | * Content-Length: "11" 24 | * Content-Type: "text/plain" 25 | * User-Agent: "Go-http-client/1.1" 26 | * X-Another-Header: "value" 27 | Hello silk. 28 | ``` 29 | 30 | * Content-Type: "text/plain; charset=utf-8" 31 | * Server: "EchoHandler" 32 | * Status: 200 33 | * Content-Length: "213" 34 | -------------------------------------------------------------------------------- /testfiles/success/echoraw.success.jsonmodes.silk.md: -------------------------------------------------------------------------------- 1 | # Comments and things 2 | 3 | * Root: "http://localhost:8080/" 4 | 5 | The server echos the request's body directly. 6 | 7 | ## `GET /json` 8 | 9 | ### Example request 10 | 11 | ```json 12 | { "name": "Mat", "comment": "Good work", "meta" : { "client" : "tester", "api" : 1.0 } } 13 | ``` 14 | 15 | === 16 | 17 | ### Example response 18 | 19 | * `Status`: `200` 20 | 21 | By defaul using the `json` qualifier in your expected request body only checks for 22 | a subset of the response. This allows you to scope your tests or to be more lenient 23 | towards future unrelated changes. Additional fields in the response do not invalidate 24 | the test. 25 | 26 | ```json 27 | { 28 | "name": "Mat", 29 | "meta" : { "client" : "tester" } 30 | } 31 | ``` 32 | 33 | ## `GET /json/same` 34 | 35 | ### Example request 36 | 37 | ```json 38 | { "name": "Mat", "comment": "Good work", "meta" : { "client" : "tester" } } 39 | ``` 40 | 41 | === 42 | 43 | ### Example response 44 | 45 | * `Status`: `200` 46 | 47 | Use the `json(strict)` qualifier in your expected request body to ensure that the json object are the same. 48 | This allows differences in order and white space. 49 | 50 | ```json(strict) 51 | { 52 | "comment": "Good work", 53 | "name": "Mat", 54 | "meta" : { 55 | "client" : "tester" 56 | } 57 | } 58 | ``` 59 | -------------------------------------------------------------------------------- /testfiles/success/example.silk.md: -------------------------------------------------------------------------------- 1 | # Comments 2 | 3 | ## `POST /comments` 4 | 5 | Create a new comment. 6 | 7 | * `Content-Type`: `"application/json"` 8 | * `Accept`: `"application/json"` 9 | 10 | Include the `name` and `comment` text in the body: 11 | 12 | ``` 13 | { 14 | "name": "Mat", 15 | "comment": "Writing tests is easy" 16 | } 17 | ``` 18 | 19 | === 20 | 21 | ### Example response 22 | 23 | * `Status`: `201` 24 | * `Content-Type`: `"application/json"` 25 | 26 | ``` 27 | { 28 | "id": "123", 29 | "name": "Mat", 30 | "comment": "Writing tests is easy" 31 | } 32 | ``` -------------------------------------------------------------------------------- /testfiles/success/issue-31.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## `GET /echo` 4 | 5 | ``` 6 | Hello silk. 7 | ``` 8 | 9 | === 10 | 11 | ``` 12 | GET /echo 13 | * Accept-Encoding: "gzip" 14 | * Content-Length: "11" 15 | * User-Agent: "Go-http-client/1.1" 16 | Hello silk. 17 | ``` 18 | 19 | * Server: "EchoHandler" 20 | * Status: 200 21 | * Content-Length: "105" 22 | -------------------------------------------------------------------------------- /testfiles/success/issue-37.silk.md: -------------------------------------------------------------------------------- 1 | # Echo server 2 | 3 | ## `GET /echo` 4 | 5 | ``` 6 | Hello silk. 7 | ``` 8 | 9 | --- 10 | 11 | ``` 12 | GET /echo 13 | * Accept-Encoding: "gzip" 14 | * Content-Length: "11" 15 | * User-Agent: "Go-http-client/1.1" 16 | Hello silk. 17 | ``` 18 | 19 | * Server: "EchoHandler" 20 | * Status: 200 21 | * Content-Length: "105" 22 | -------------------------------------------------------------------------------- /testutil/doc.go: -------------------------------------------------------------------------------- 1 | // Package testutil contains tools useful for testing. 2 | package testutil 3 | -------------------------------------------------------------------------------- /testutil/echo.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | ) 15 | 16 | // EchoHandler gets an http.Handler that echos request data 17 | // back in the response. 18 | func EchoHandler() http.Handler { 19 | return http.HandlerFunc(handleEcho) 20 | } 21 | 22 | // EchoDataHandler gets an http.Handler that echos request data 23 | // back in the response in JSON format. 24 | func EchoDataHandler() http.Handler { 25 | return http.HandlerFunc(handleEchoData) 26 | } 27 | 28 | // EchoRawHandler gets an http.Handler that echos request's body only. 29 | func EchoRawHandler() http.Handler { 30 | return http.HandlerFunc(handleEchoRaw) 31 | } 32 | 33 | func handleEcho(w http.ResponseWriter, r *http.Request) { 34 | // set Server header 35 | w.Header().Set("Server", "EchoHandler") 36 | if len(r.Cookies()) > 0 { 37 | // echo cookies 38 | for _, cookie := range r.Cookies() { 39 | http.SetCookie(w, cookie) 40 | } 41 | } 42 | // write summary of request 43 | fmt.Fprintln(w, strings.ToUpper(r.Method), r.URL.Path) 44 | // put in the Content-Length 45 | var bodybuf bytes.Buffer 46 | if _, err := io.Copy(&bodybuf, r.Body); err != nil { 47 | log.Println("copying request into buffer failed:", err) 48 | } 49 | r.Header.Set("Content-Length", strconv.Itoa(bodybuf.Len())) 50 | // write parameters 51 | writeSortedQuery(w, r.URL.Query()) 52 | // write headers 53 | writeSortedHeaders(w, r.Header) 54 | // write cookies 55 | if len(r.Cookies()) > 0 { 56 | // write sorted cookies out (sorted by name) 57 | writeSortedCookies(w, r.Cookies(), r) 58 | } 59 | // write body 60 | if _, err := io.Copy(w, &bodybuf); err != nil { 61 | log.Println("copying request into response failed:", err) 62 | } 63 | } 64 | 65 | func handleEchoData(w http.ResponseWriter, r *http.Request) { 66 | // set Server header 67 | w.Header().Set("Server", "EchoDataHandler") 68 | 69 | out := make(map[string]interface{}) 70 | out["method"] = r.Method 71 | out["path"] = r.URL.Path 72 | 73 | var bodybuf bytes.Buffer 74 | if _, err := io.Copy(&bodybuf, r.Body); err != nil { 75 | log.Println("copying request into buffer failed:", err) 76 | } 77 | r.Header.Set("Content-Length", strconv.Itoa(bodybuf.Len())) 78 | for k := range r.Header { 79 | for _, v := range r.Header[k] { 80 | out[k] = v 81 | } 82 | } 83 | for k, vs := range r.URL.Query() { 84 | out[k] = vs 85 | } 86 | out["bodystr"] = bodybuf.String() 87 | var bodyData interface{} 88 | if err := json.NewDecoder(&bodybuf).Decode(&bodyData); err != nil { 89 | out["bodyerr"] = err.Error() 90 | } 91 | out["body"] = bodyData 92 | if err := json.NewEncoder(w).Encode(out); err != nil { 93 | panic(err) 94 | } 95 | } 96 | 97 | func handleEchoRaw(w http.ResponseWriter, r *http.Request) { 98 | // set Server header 99 | w.Header().Set("Server", "EchoRawHandler") 100 | 101 | // read body 102 | var bodybuf bytes.Buffer 103 | if _, err := io.Copy(&bodybuf, r.Body); err != nil { 104 | log.Println("copying request into buffer failed:", err) 105 | } 106 | // write body 107 | if _, err := io.Copy(w, &bodybuf); err != nil { 108 | log.Println("copying request into response failed:", err) 109 | } 110 | } 111 | 112 | func writeSortedHeaders(w io.Writer, headers http.Header) { 113 | // get header keys 114 | var keys []string 115 | for k := range headers { 116 | keys = append(keys, k) 117 | } 118 | sort.Strings(keys) 119 | for _, k := range keys { 120 | for _, v := range headers[k] { 121 | vb, err := json.Marshal(v) 122 | if err != nil { 123 | log.Println("silk/testutil: cannot marshal header value:", err) 124 | continue 125 | } 126 | fmt.Fprintln(w, "* "+k+":", string(vb)) 127 | } 128 | } 129 | } 130 | 131 | func writeSortedCookies(w io.Writer, cookies []*http.Cookie, r *http.Request) { 132 | var keys []string 133 | for _, c := range cookies { 134 | keys = append(keys, c.Name) 135 | } 136 | sort.Strings(keys) 137 | for _, k := range keys { 138 | cookie, err := r.Cookie(k) 139 | if err != nil { 140 | log.Println("failed to get cookie:", err) 141 | continue 142 | } 143 | fmt.Fprintln(w, "* Cookie:", cookie.String()) 144 | } 145 | } 146 | 147 | func writeSortedQuery(w io.Writer, query url.Values) { 148 | // get header keys 149 | var keys []string 150 | for k := range query { 151 | keys = append(keys, k) 152 | } 153 | sort.Strings(keys) 154 | for _, k := range keys { 155 | var vals []string 156 | for _, v := range query[k] { 157 | vals = append(vals, v) 158 | } 159 | sort.Strings(vals) 160 | for _, v := range vals { 161 | fmt.Fprintln(w, "* ?"+k+"="+v) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const version = "0.5.2" 4 | --------------------------------------------------------------------------------