├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── go.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── array.go ├── boolean.go ├── core.go ├── examples_test.go ├── exports.go ├── go.mod ├── go.sum ├── helper.go ├── integration_test.go ├── logo.png ├── number.go ├── object.go ├── string.go └── testdata ├── big-fat-payload-actual.json └── big-fat-payload-expected.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @kinbiko 2 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at roger@kinbiko.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## PRs 4 | 5 | If you wish to contribute a change to this project, please create an issue first to discuss. 6 | If you do not raise an issue before submitting a (significant) PR then your PR may be dismissed without much consideration. 7 | 8 | PRs only improving the documentation are welcome without raising an issue first. 9 | 10 | Ensure that: 11 | 12 | 1. The tests pass. 13 | 1. The linter has 0 issues. 14 | 1. You don't introduce any new dependencies (ask in an issue if you feel strongly that it's necessary). 15 | 1. You follow the existing commit message convention. 16 | 17 | ## Goals of `jsonassert` 18 | 19 | - Accurately solve the problem of "Are these two JSONs semantically the same?" 20 | - Be easy to comprehend 21 | - Be easy to maintain 22 | - Have no dependencies outside of the standard library 23 | - Be well tested 24 | 25 | ### Non-goals 26 | 27 | - Performance. There's a lot of unnecessary back and forth, mainly for the purpose of maintainability. If performance is crucial to you, feel free to fork the repo. Changes which don't negatively impact the readability and maintainability of the project are likely to get pulled if so requested, otherwise I'm happy to provide links to more performant forks in the README of this project. 28 | 29 | ## Structure 30 | 31 | The `exports.go` file contains all the exported (publicly available) code. This file is the entry point to this entire package, and should be kept well documented and sparsely coded. The primary method is the `Assertf` function, which calls `pathassert`. 32 | 33 | `pathassertf` is triggers the main algorithm: 34 | 35 | - Keep track of where in the JSON we're at with the first string arg. This will get further appended onto for each nested object or array traversal (breadth-first). 36 | - Checks that both representations are in fact JSON 37 | - Checks if the string reps of the two JSON are literally equal. 38 | - Checks if the types of the string reps are unequal, if they are, print and abort. 39 | - Calls a specific `check*` function for each of the possible JSON types against the extracted value (from JSON string to Go). These extraction and check functions live in the file named after each type. 40 | - If the type is object or array, each sub-property (key or element) will be compared by turning the value into a JSON string again, and calling `pathassertf` with an appropriately appended location inside each JSON. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Describe an issue you've seen with this package 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ### What exactly did you do? 10 | 11 | 12 | 13 | ### What did you expect would happen? 14 | 15 | 16 | 17 | ### What actually happened? 18 | 19 | ```console 20 | any output goes here 21 | ``` 22 | 23 | ### Additional info 24 | 25 | - Output from `go version`: [e.g. `go version go1.14.5 darwin/amd64`] 26 | - Version of this package: [e.g. `v.0.9.0`] 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## What problem are you trying to solve? 10 | 11 | ## Describe how you wish this package would help you solve the problem 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Questions about this package 4 | title: "[Question]" 5 | labels: question 6 | assignees: "" 7 | --- 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ## Checklist 8 | 9 | - [ ] I have done a self-review of the PR. 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | reviewers: 13 | - "kinbiko" 14 | commit-message: 15 | prefix: "chore:" 16 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "35 16 * * 1" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Set up Go 9 | uses: actions/setup-go@v5 10 | with: 11 | go-version: 1.22 12 | id: go 13 | 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@v4 16 | 17 | - name: Test (race) 18 | run: make coverage 19 | 20 | - name: Coverage 21 | uses: shogo82148/actions-goveralls@v1 22 | with: 23 | path-to-profile: profile.cov 24 | 25 | - name: Lint 26 | run: make lint 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | tags 15 | 16 | bin/ 17 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 1m 3 | 4 | linters-settings: 5 | errcheck: 6 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 7 | # default is false: such cases aren't reported by default. 8 | check-type-assertions: true 9 | 10 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 11 | # default is false: such cases aren't reported by default. 12 | check-blank: false 13 | 14 | # [deprecated] comma-separated list of pairs of the form pkg:regex 15 | # the regex is used to ignore names within pkg. (default "fmt:.*"). 16 | # see https://github.com/kisielk/errcheck#the-deprecated-method for details 17 | # ignore: fmt:.*,io/ioutil:^Read.* 18 | 19 | # path to a file containing a list of functions to exclude from checking 20 | # see https://github.com/kisielk/errcheck#excluding-functions for details 21 | # exclude: /path/to/file.txt 22 | 23 | funlen: 24 | lines: 50 25 | statements: 40 26 | 27 | govet: 28 | # report about shadowed variables 29 | shadow: true 30 | 31 | # enable or disable analyzers by name 32 | # enable: 33 | # - atomicalign 34 | enable-all: true 35 | disable: 36 | - fieldalignment 37 | # disable-all: false 38 | revive: 39 | # minimal confidence for issues, default is 0.8 40 | min-confidence: 0.8 41 | gofmt: 42 | # simplify code: gofmt with `-s` option, true by default 43 | simplify: true 44 | goimports: 45 | # put imports beginning with prefix after 3rd-party packages; 46 | # it's a comma-separated list of prefixes 47 | local-prefixes: github.com/kinbiko/bugsnag 48 | gocyclo: 49 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 50 | # This check is set to an unreasonably low number by most developers' 51 | # standards to track the code standard over time 52 | min-complexity: 10 53 | gocognit: 54 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 55 | # This check is a more useful cyclomatic complexity called cognitive complexity, 56 | # where nested if/for is weighted more, and only one point regardless of 57 | # cases in a switch. 58 | min-complexity: 11 59 | dupl: 60 | # tokens count to trigger issue, 150 by default 61 | threshold: 100 62 | goconst: 63 | # minimal length of string constant, 3 by default 64 | min-len: 10 65 | # minimal occurrences count to trigger, 3 by default 66 | min-occurrences: 3 67 | 68 | # packages-with-error-messages: 69 | # specify an error message to output when a blacklisted package is used 70 | # github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" 71 | misspell: 72 | # Correct spellings using locale preferences for US or UK. 73 | # Default is to use a neutral variety of English. 74 | # Setting locale to US will correct the British spelling of 'colour' to 'color'. 75 | locale: US 76 | # ignore-words: 77 | # - someword 78 | lll: 79 | # max line length, lines longer will be reported. Default is 120. 80 | # '\t' is counted as 1 character by default, and can be changed with the tab-width option 81 | line-length: 165 82 | # tab width in spaces. Default to 1. 83 | tab-width: 4 84 | unused: 85 | # treat code as a program (not a library) and report unused exported identifiers; default is false. 86 | # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: 87 | # if it's called for subdir of a project it can't find funcs usages. All text editor integrations 88 | # with golangci-lint call it on a directory with the changed file. 89 | check-exported: false 90 | unparam: 91 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 92 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 93 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 94 | # with golangci-lint call it on a directory with the changed file. 95 | check-exported: true 96 | nakedret: 97 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 98 | # Naked returns can go plop itself 99 | max-func-lines: 0 100 | prealloc: 101 | # XXX: we don't recommend using this linter before doing performance profiling. 102 | # For most programs usage of prealloc will be a premature optimization. 103 | 104 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 105 | # True by default. 106 | simple: true 107 | range-loops: true # Report preallocation suggestions on range loops, true by default 108 | for-loops: false # Report preallocation suggestions on for loops, false by default 109 | gocritic: 110 | # Which checks should be enabled; can't be combined with 'disabled-checks'; 111 | # See https://go-critic.github.io/overview#checks-overview 112 | # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` 113 | # By default list of stable checks is used. 114 | # enabled-checks: 115 | # - badCond 116 | 117 | # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty 118 | # disabled-checks: 119 | 120 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. 121 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 122 | enabled-tags: 123 | - diagnostic 124 | - style 125 | - performance 126 | 127 | settings: # settings passed to gocritic 128 | captLocal: # must be valid enabled check name 129 | paramsOnly: true 130 | rangeValCopy: 131 | sizeThreshold: 64 132 | godox: 133 | # report any comments starting with keywords, this is useful for TODO or FIXME comments that 134 | # might be left in the code accidentally and should be resolved before merging 135 | keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting 136 | - TODO 137 | - FIXME 138 | dogsled: 139 | # checks assignments with too many blank identifiers; default is 2 140 | max-blank-identifiers: 2 141 | 142 | whitespace: 143 | multi-if: false # Enforces newlines (or comments) after every multi-line if statement 144 | multi-func: false # Enforces newlines (or comments) after every multi-line function signature 145 | 146 | linters: 147 | disable: 148 | - wsl 149 | - godot 150 | - nlreturn 151 | 152 | - depguard 153 | 154 | - gci # This conflicts with goimports 155 | - varnamelen # This has too many false positives around indexes etc to be useful 156 | presets: 157 | - bugs 158 | - complexity 159 | - format 160 | - performance 161 | - style 162 | - unused 163 | fast: false 164 | 165 | issues: 166 | # Excluding configuration per-path, per-linter, per-text and per-source 167 | exclude-rules: 168 | # Exclude some linters from running on tests files. 169 | - path: _test\.go 170 | linters: 171 | - cyclop 172 | - dupl 173 | - errcheck 174 | - errchkjson 175 | - exhaustivestruct 176 | - forbidigo 177 | - funlen 178 | - gocognit 179 | - gocyclo 180 | - gomnd 181 | - lll 182 | - stylecheck 183 | - testpackage 184 | - varnamelen 185 | - maintidx 186 | - path: \.go 187 | linters: 188 | - err113 189 | 190 | # Independently from option `exclude` we use default exclude patterns, 191 | # it can be disabled by this option. To list all 192 | # excluded by default patterns execute `golangci-lint run --help`. 193 | # Default value for this option is true. 194 | exclude-use-default: false 195 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 196 | max-issues-per-linter: 0 197 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 198 | max-same-issues: 0 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Roger Guldbrandsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LINTER_VERSION := v1.61.0 2 | 3 | .PHONY: check 4 | check: lint test 5 | 6 | .PHONY: get-deps 7 | get-deps: 8 | go get -v -t -d ./... 9 | 10 | .PHONY: lint 11 | lint: ./bin/linter 12 | ./bin/linter run ./... 13 | 14 | .PHONY: test 15 | test: 16 | go test -race -count=1 ./... 17 | 18 | .PHONY: coverage 19 | coverage: 20 | go test -race -v -coverprofile=profile.cov -covermode=atomic ./... 21 | 22 | bin/linter: Makefile 23 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ./bin $(LINTER_VERSION) 24 | mv ./bin/golangci-lint ./bin/linter 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](./logo.png) 2 | 3 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) 4 | [![Build Status](https://github.com/kinbiko/jsonassert/workflows/Go/badge.svg)](https://github.com/kinbiko/jsonassert/actions) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/kinbiko/jsonassert)](https://goreportcard.com/report/github.com/kinbiko/jsonassert) 6 | [![Coverage Status](https://coveralls.io/repos/github/kinbiko/jsonassert/badge.svg)](https://coveralls.io/github/kinbiko/jsonassert) 7 | [![Latest version](https://img.shields.io/github/tag/kinbiko/jsonassert.svg?label=latest%20version&style=flat)](https://github.com/kinbiko/jsonassert/releases) 8 | [![Go Documentation](http://img.shields.io/badge/godoc-documentation-blue.svg?style=flat)](https://pkg.go.dev/github.com/kinbiko/jsonassert) 9 | [![License](https://img.shields.io/github/license/kinbiko/jsonassert.svg?style=flat)](https://github.com/kinbiko/jsonassert/blob/master/LICENSE) 10 | 11 | It's difficult to confirm that a JSON payload, e.g. a HTTP request or response body, does indeed look the way you want using the built-in Go testing package. 12 | `jsonassert` is an easy-to-use Go test assertion library for verifying that two representations of JSON are semantically equal. 13 | 14 | ## Usage 15 | 16 | Create a new `*jsonassert.Asserter` in your test and use this to make assertions against your JSON payloads: 17 | 18 | ```go 19 | func TestWhatever(t *testing.T) { 20 | ja := jsonassert.New(t) 21 | // find some sort of payload 22 | name := "River Tam" 23 | age := 16 24 | ja.Assertf(payload, ` 25 | { 26 | "name": "%s", 27 | "age": %d, 28 | "averageTestScore": "%s", 29 | "skills": [ 30 | { "name": "martial arts", "level": 99 }, 31 | { "name": "intelligence", "level": 100 }, 32 | { "name": "mental fortitude", "level": 4 } 33 | ] 34 | }`, name, age, "99%") 35 | } 36 | ``` 37 | 38 | You may pass in `fmt.Sprintf` arguments after the expected JSON structure. 39 | This feature may be useful for the case when you already have variables in your test with the expected data or when your expected JSON contains a `%` character which could be misinterpreted as a format directive. 40 | 41 | `ja.Assertf()` supports assertions against **strings only**. 42 | 43 | ### Check for presence only 44 | 45 | Some properties of a JSON payload may be difficult to know in advance. 46 | E.g. timestamps, UUIDs, or other randomly assigned values. 47 | 48 | For these types of values, place the string `"<>"` as the expected value, and `jsonassert` will only verify that this key exists (i.e. the actual JSON has the expected key, and its value is not `null`), but this does not check its value. 49 | 50 | For example: 51 | 52 | ```go 53 | func TestWhatever(t *testing.T) { 54 | ja := jsonassert.New(t) 55 | ja.Assertf(` 56 | { 57 | "time": "2019-01-28T21:19:42", 58 | "uuid": "94ae1a31-63b2-4a55-a478-47764b60c56b" 59 | }`, ` 60 | { 61 | "time": "<>", 62 | "uuid": "<>" 63 | }`) 64 | } 65 | ``` 66 | 67 | The above will pass your test, but: 68 | 69 | ```go 70 | func TestWhatever(t *testing.T) { 71 | ja := jsonassert.New(t) 72 | ja.Assertf(` 73 | { 74 | "date": "2019-01-28T21:19:42", 75 | "uuid": null 76 | }`, ` 77 | { 78 | "time": "<>", 79 | "uuid": "<>" 80 | }`) 81 | } 82 | ``` 83 | 84 | The above will fail your tests because the `time` key was not present in the actual JSON, and the `uuid` was `null`. 85 | 86 | ### Ignore ordering in arrays 87 | 88 | If your JSON payload contains an array with elements whose ordering is not deterministic, then you can use the `"<>"` directive as the first element of the array in question: 89 | 90 | ```go 91 | func TestUnorderedArray(t *testing.T) { 92 | ja := jsonassert.New(t) 93 | payload := `["bar", "foo", "baz"]` 94 | ja.Assertf(payload, `["foo", "bar", "baz"]`) // Order matters, will fail your test. 95 | ja.Assertf(payload, `["<>", "foo", "bar", "baz"]`) // Order agnostic, will pass your test. 96 | } 97 | ``` 98 | 99 | ## Docs 100 | 101 | You can find the [GoDocs for this package here](https://pkg.go.dev/github.com/kinbiko/jsonassert). 102 | 103 | ## Contributing 104 | 105 | Contributions are welcome. Please read the [contribution guidelines](./.github/CONTRIBUTING.md) and discuss feature requests in an issue before opening a PR. 106 | -------------------------------------------------------------------------------- /array.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func (a *Asserter) checkArray(path string, act, exp []interface{}) { 10 | a.tt.Helper() 11 | if len(exp) > 0 && exp[0] == "<>" { 12 | a.checkArrayUnordered(path, act, exp[1:]) 13 | } else { 14 | a.checkArrayOrdered(path, act, exp) 15 | } 16 | } 17 | 18 | //nolint:gocognit,gocyclo,cyclop // function is actually still readable 19 | func (a *Asserter) checkArrayUnordered(path string, act, exp []interface{}) { 20 | a.tt.Helper() 21 | if len(act) != len(exp) { 22 | a.tt.Errorf("length of arrays at '%s' were different. Expected array to be of length %d, but contained %d element(s)", path, len(exp), len(act)) 23 | serializedAct, serializedExp := serialize(act), serialize(exp) 24 | if len(serializedAct+serializedExp) < maxMsgCharCount { 25 | a.tt.Errorf("actual JSON at '%s' was: %+v, but expected JSON was: %+v, potentially in a different order", path, serializedAct, serializedExp) 26 | } else { 27 | a.tt.Errorf("actual JSON at '%s' was:\n%+v\nbut expected JSON was:\n%+v,\npotentially in a different order", path, serializedAct, serializedExp) 28 | } 29 | return 30 | } 31 | 32 | for i, actEl := range act { 33 | found := false 34 | for _, expEl := range exp { 35 | if a.deepEqual(actEl, expEl) { 36 | found = true 37 | } 38 | } 39 | if !found { 40 | serializedEl := serialize(actEl) 41 | if len(serializedEl) < maxMsgCharCount { 42 | a.tt.Errorf("actual JSON at '%s[%d]' contained an unexpected element: %s", path, i, serializedEl) 43 | } else { 44 | a.tt.Errorf("actual JSON at '%s[%d]' contained an unexpected element:\n%s", path, i, serializedEl) 45 | } 46 | } 47 | } 48 | 49 | for i, expEl := range exp { 50 | found := false 51 | for _, actEl := range act { 52 | found = found || a.deepEqual(actEl, expEl) 53 | } 54 | if !found { 55 | serializedEl := serialize(expEl) 56 | if len(serializedEl) < maxMsgCharCount { 57 | a.tt.Errorf("expected JSON at '%s[%d]': %s was missing from actual payload", path, i, serializedEl) 58 | } else { 59 | a.tt.Errorf("expected JSON at '%s[%d]':\n%s\nwas missing from actual payload", path, i, serializedEl) 60 | } 61 | } 62 | } 63 | } 64 | 65 | func (a *Asserter) checkArrayOrdered(path string, act, exp []interface{}) { 66 | a.tt.Helper() 67 | if len(act) != len(exp) { 68 | a.tt.Errorf("length of arrays at '%s' were different. Expected array to be of length %d, but contained %d element(s)", path, len(exp), len(act)) 69 | serializedAct, serializedExp := serialize(act), serialize(exp) 70 | if len(serializedAct+serializedExp) < maxMsgCharCount { 71 | a.tt.Errorf("actual JSON at '%s' was: %+v, but expected JSON was: %+v", path, serializedAct, serializedExp) 72 | } else { 73 | a.tt.Errorf("actual JSON at '%s' was:\n%+v\nbut expected JSON was:\n%+v", path, serializedAct, serializedExp) 74 | } 75 | return 76 | } 77 | for i := range act { 78 | a.pathassertf(path+fmt.Sprintf("[%d]", i), serialize(act[i]), serialize(exp[i])) 79 | } 80 | } 81 | 82 | func extractArray(s string) ([]interface{}, bool) { 83 | s = strings.TrimSpace(s) 84 | if s == "" { 85 | return nil, false 86 | } 87 | var arr []interface{} 88 | return arr, json.Unmarshal([]byte(s), &arr) == nil 89 | } 90 | -------------------------------------------------------------------------------- /boolean.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import "fmt" 4 | 5 | func extractBoolean(b string) (bool, error) { 6 | if b == "true" { 7 | return true, nil 8 | } 9 | if b == "false" { 10 | return false, nil 11 | } 12 | return false, fmt.Errorf("could not parse '%s' as a boolean", b) 13 | } 14 | 15 | func (a *Asserter) checkBoolean(path string, act, exp bool) { 16 | a.tt.Helper() 17 | if act != exp { 18 | a.tt.Errorf("expected boolean at '%s' to be %v but was %v", path, exp, act) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // The length at which to consider a message too long to fit on a single line 10 | const maxMsgCharCount = 50 11 | 12 | //nolint:gocyclo,cyclop // function is actually still readable 13 | func (a *Asserter) pathassertf(path, act, exp string) { 14 | a.tt.Helper() 15 | if act == exp { 16 | return 17 | } 18 | actType, err := findType(act) 19 | if err != nil { 20 | a.tt.Errorf("'actual' JSON is not valid JSON: " + err.Error()) 21 | return 22 | } 23 | expType, err := findType(exp) 24 | if err != nil { 25 | a.tt.Errorf("'expected' JSON is not valid JSON: " + err.Error()) 26 | return 27 | } 28 | 29 | // If we're only caring about the presence of the key, then don't bother checking any further 30 | if expPresence, _ := extractString(exp); expPresence == "<>" { 31 | if actType == jsonNull { 32 | a.tt.Errorf(`expected the presence of any value at '%s', but was absent`, path) 33 | } 34 | return 35 | } 36 | 37 | if actType != expType { 38 | a.tt.Errorf("actual JSON (%s) and expected JSON (%s) were of different types at '%s'", actType, expType, path) 39 | return 40 | } 41 | switch actType { //nolint:exhaustive // already know it's valid JSON and not null 42 | case jsonBoolean: 43 | actBool, _ := extractBoolean(act) 44 | expBool, _ := extractBoolean(exp) 45 | a.checkBoolean(path, actBool, expBool) 46 | case jsonNumber: 47 | actNumber, _ := extractNumber(act) 48 | expNumber, _ := extractNumber(exp) 49 | a.checkNumber(path, actNumber, expNumber) 50 | case jsonString: 51 | actString, _ := extractString(act) 52 | expString, _ := extractString(exp) 53 | a.checkString(path, actString, expString) 54 | case jsonObject: 55 | actObject, _ := extractObject(act) 56 | expObject, _ := extractObject(exp) 57 | a.checkObject(path, actObject, expObject) 58 | case jsonArray: 59 | actArray, _ := extractArray(act) 60 | expArray, _ := extractArray(exp) 61 | a.checkArray(path, actArray, expArray) 62 | } 63 | } 64 | 65 | func serialize(a interface{}) string { 66 | //nolint:errchkjson // Can be confident this won't return an error: the 67 | // input will be a nested part of valid JSON, thus valid JSON 68 | bytes, _ := json.Marshal(a) 69 | return string(bytes) 70 | } 71 | 72 | type jsonType string 73 | 74 | const ( 75 | jsonString jsonType = "string" 76 | jsonNumber jsonType = "number" 77 | jsonBoolean jsonType = "boolean" 78 | jsonNull jsonType = "null" 79 | jsonObject jsonType = "object" 80 | jsonArray jsonType = "array" 81 | jsonTypeUnknown jsonType = "unknown" 82 | ) 83 | 84 | func findType(j string) (jsonType, error) { 85 | j = strings.TrimSpace(j) 86 | if _, ok := extractString(j); ok { 87 | return jsonString, nil 88 | } 89 | if _, ok := extractNumber(j); ok { 90 | return jsonNumber, nil 91 | } 92 | if j == "null" { 93 | return jsonNull, nil 94 | } 95 | if _, ok := extractObject(j); ok { 96 | return jsonObject, nil 97 | } 98 | if _, err := extractBoolean(j); err == nil { 99 | return jsonBoolean, nil 100 | } 101 | if _, ok := extractArray(j); ok { 102 | return jsonArray, nil 103 | } 104 | return jsonTypeUnknown, fmt.Errorf(`unable to identify JSON type of "%s"`, j) 105 | } 106 | 107 | // *testing.T has a Helper() func that allow testing tools like this package to 108 | // ignore their own frames when calling Errorf on *testing.T instances. 109 | // This interface is here to avoid breaking backwards compatibility in terms of 110 | // the interface we expect in New. 111 | type tt interface { 112 | Printer 113 | Helper() 114 | } 115 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | package jsonassert_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kinbiko/jsonassert" 7 | ) 8 | 9 | type printer struct{} 10 | 11 | func (p *printer) Errorf(format string, args ...interface{}) { 12 | fmt.Println(fmt.Sprintf(format, args...)) 13 | } 14 | 15 | // using the varible name 't' to mimic a *testing.T variable 16 | // 17 | //nolint:gochecknoglobals // this is global to make the examples look like valid test code 18 | var t *printer 19 | 20 | func ExampleNew() { 21 | ja := jsonassert.New(t) 22 | ja.Assertf(`{"hello":"world"}`, ` 23 | { 24 | "hello": "world" 25 | }`) 26 | } 27 | 28 | func ExampleAsserter_Assertf_formatArguments() { 29 | ja := jsonassert.New(t) 30 | expTestScore := "28%" 31 | ja.Assertf( 32 | `{ "name": "Jayne Cobb", "age": 36, "averageTestScore": "88%" }`, 33 | `{ "name": "Jayne Cobb", "age": 36, "averageTestScore": "%s" }`, expTestScore, 34 | ) 35 | // output: 36 | // expected string at '$.averageTestScore' to be '28%' but was '88%' 37 | } 38 | 39 | func ExampleAsserter_Assertf_presenceOnly() { 40 | ja := jsonassert.New(t) 41 | ja.Assertf(`{"hi":"not the right key name"}`, ` 42 | { 43 | "hello": "<>" 44 | }`) 45 | // output: 46 | // unexpected object key(s) ["hi"] found at '$' 47 | // expected object key(s) ["hello"] missing at '$' 48 | } 49 | 50 | func ExampleAsserter_Assertf_unorderedArray() { 51 | ja := jsonassert.New(t) 52 | ja.Assertf( 53 | `["zero", "one", "two"]`, 54 | `["<>", "one", "two", "three"]`, 55 | ) 56 | // output: 57 | // actual JSON at '$[0]' contained an unexpected element: "zero" 58 | // expected JSON at '$[2]': "three" was missing from actual payload 59 | } 60 | -------------------------------------------------------------------------------- /exports.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package jsonassert is a Go test assertion library for verifying that two 3 | representations of JSON are semantically equal. Create a new 4 | 5 | *jsonassert.Asserter 6 | 7 | in your test and use this to make assertions against your JSON payloads: 8 | 9 | ja := jsonassert.New(t) 10 | 11 | E.g. for the JSON 12 | 13 | {"hello": "world"} 14 | 15 | you may use an expected JSON of 16 | 17 | {"hello": "%s"} 18 | 19 | along with the "world" format argument. For example: 20 | 21 | ja.Assertf(`{"hello": "world"}`, `{"hello":"%s"}`, "world") 22 | 23 | You may wish to make assertions against the *presence* of a value, but not 24 | against its value. For example: 25 | 26 | ja.Assertf(`{"uuid": "94ae1a31-63b2-4a55-a478-47764b60c56b"}`, `{"uuid":"<>"}`) 27 | 28 | will verify that the UUID field is present, but does not check its actual value. 29 | You may use "<>" against any type of value. The only exception is null, which 30 | will result in an assertion failure. 31 | 32 | If you don't know / care about the order of the elements in an array in your 33 | payload, you can ignore the ordering: 34 | 35 | payload := `["bar", "foo", "baz"]` 36 | ja.Assertf(payload, `["<>", "foo", "bar", "baz"]`) 37 | 38 | The above will verify that "foo", "bar", and "baz" are exactly the elements in 39 | the payload, but will ignore the order in which they appear. 40 | */ 41 | package jsonassert 42 | 43 | import ( 44 | "fmt" 45 | ) 46 | 47 | // Printer is any type that has a testing.T-like Errorf function. 48 | // You probably want to pass in a *testing.T instance here if you are using 49 | // this in your tests. 50 | type Printer interface { 51 | Errorf(msg string, args ...interface{}) 52 | } 53 | 54 | // Asserter represents the main type within the jsonassert package. 55 | // See Asserter.Assertf for the main use of this package. 56 | type Asserter struct { 57 | tt 58 | } 59 | 60 | /* 61 | New creates a new *jsonassert.Asserter for making assertions against JSON payloads. 62 | This type can be reused. I.e. if you are using jsonassert as part of your tests, 63 | you only need one *jsonassert.Asseter per (sub)test. 64 | In most cases, this will look something like 65 | 66 | ja := jsonassert.New(t) 67 | */ 68 | func New(p Printer) *Asserter { 69 | // Initially this package was written without the assumption that the 70 | // provided Printer will implement testing.tt, which includes the Helper() 71 | // function to get better stacktraces in your testing utility functions. 72 | // This assumption was later added in order to get more accurate stackframe 73 | // information in test failures. In most cases users will pass in a 74 | // *testing.T to this function, which does adhere to that interface. 75 | // However, in order to be backwards compatible we also permit the use of 76 | // printers that do not implement Helper(). This is done by wrapping the 77 | // provided Printer into another struct that implements a NOOP Helper 78 | // method. 79 | if t, ok := p.(tt); ok { 80 | return &Asserter{tt: t} 81 | } 82 | return &Asserter{tt: &noopHelperTT{Printer: p}} 83 | } 84 | 85 | /* 86 | Assertf takes two strings, the first being the 'actual' JSON that you wish to 87 | make assertions against. The second string is the 'expected' JSON, which 88 | can be treated as a template for additional format arguments. 89 | If any discrepancies are found, these will be given to the Errorf function in the Printer. 90 | E.g. for the JSON 91 | 92 | {"hello": "world"} 93 | 94 | you may use an expected JSON of 95 | 96 | {"hello": "%s"} 97 | 98 | along with the "world" format argument. For example: 99 | 100 | ja.Assertf(`{"hello": "world"}`, `{"hello":"%s"}`, "world") 101 | 102 | You may also use format arguments in the case when your expected JSON contains 103 | a percent character, which would otherwise be interpreted as a 104 | format-directive. 105 | 106 | ja.Assertf(`{"averageTestScore": "99%"}`, `{"averageTestScore":"%s"}`, "99%") 107 | 108 | You may wish to make assertions against the *presence* of a value, but not 109 | against its value. For example: 110 | 111 | ja.Assertf(`{"uuid": "94ae1a31-63b2-4a55-a478-47764b60c56b"}`, `{"uuid":"<>"}`) 112 | 113 | will verify that the UUID field is present, but does not check its actual value. 114 | You may use "<>" against any type of value. The only exception is null, which 115 | will result in an assertion failure. 116 | 117 | If you don't know / care about the order of the elements in an array in your 118 | payload, you can ignore the ordering: 119 | 120 | payload := `["bar", "foo", "baz"]` 121 | ja.Assertf(payload, `["<>", "foo", "bar", "baz"]`) 122 | 123 | The above will verify that "foo", "bar", and "baz" are exactly the elements in 124 | the payload, but will ignore the order in which they appear. 125 | */ 126 | func (a *Asserter) Assertf(actualJSON, expectedJSON string, fmtArgs ...interface{}) { 127 | a.tt.Helper() 128 | a.pathassertf("$", actualJSON, fmt.Sprintf(expectedJSON, fmtArgs...)) 129 | } 130 | 131 | // Assert works like Assertf, but does not accept fmt.Sprintf directives. 132 | // See Assert for details. 133 | func (a *Asserter) Assert(actualJSON, expectedJSON string) { 134 | a.tt.Helper() 135 | a.pathassertf("$", actualJSON, expectedJSON) 136 | } 137 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kinbiko/jsonassert 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinbiko/jsonassert/689128796b01dbc5c1e7df7097896b55ac27a490/go.sum -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | // noopHelperTT is used to wrap the Printer in the case that users pass in an 4 | // Printer which does not implement a Helper() method. *testing.T does 5 | // implement this method so it is believed that this utility will be largely 6 | // unused. 7 | type noopHelperTT struct { 8 | Printer 9 | } 10 | 11 | // Helper does nothing, intentionally. See New(Printer). 12 | func (*noopHelperTT) Helper() { 13 | // Intentional NOOP 14 | } 15 | 16 | // deepEqualityPrinter is a utility Printer that lets the jsonassert package 17 | // verify equality internally without affecting the external facing output, 18 | // e.g. to verify where one potential candidate is matching or not. 19 | type deepEqualityPrinter struct{ count int } 20 | 21 | func (p *deepEqualityPrinter) Errorf(_ string, _ ...interface{}) { p.count++ } 22 | func (p *deepEqualityPrinter) Helper() { /* Intentional NOOP */ } 23 | 24 | func (a *Asserter) deepEqual(act, exp interface{}) bool { 25 | p := &deepEqualityPrinter{count: 0} 26 | deepEqualityAsserter := &Asserter{tt: p} 27 | deepEqualityAsserter.pathassertf("", serialize(act), serialize(exp)) 28 | return p.count == 0 29 | } 30 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package jsonassert_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/kinbiko/jsonassert" 9 | ) 10 | 11 | func TestAssertf(t *testing.T) { 12 | t.Parallel() 13 | t.Run("primitives", func(t *testing.T) { 14 | t.Parallel() 15 | t.Run("equality", func(t *testing.T) { 16 | t.Parallel() 17 | for name, tc := range map[string]*testCase{ 18 | "0 bytes": {``, ``, nil}, 19 | "null": {`null`, `null`, nil}, 20 | "empty objects": {`{}`, `{ }`, nil}, 21 | "empty arrays": {`[]`, `[ ]`, nil}, 22 | "empty strings": {`""`, `""`, nil}, 23 | "zero": {`0`, `0`, nil}, 24 | "booleans": {`false`, `false`, nil}, 25 | "positive ints": {`125`, `125`, nil}, 26 | "negative ints": {`-1245`, `-1245`, nil}, 27 | "positive floats": {`12.45`, `12.45`, nil}, 28 | "negative floats": {`-12.345`, `-12.345`, nil}, 29 | "strings": {`"hello world"`, `"hello world"`, nil}, 30 | } { 31 | t.Run(name, func(t *testing.T) { tc.check(t) }) 32 | } 33 | }) 34 | 35 | t.Run("difference", func(t *testing.T) { 36 | t.Parallel() 37 | for name, tc := range map[string]*testCase{ 38 | "types": {`"true"`, `true`, []string{`actual JSON (string) and expected JSON (boolean) were of different types at '$'`}}, 39 | "0 bytes v null": {``, `null`, []string{`'actual' JSON is not valid JSON: unable to identify JSON type of ""`}}, 40 | "booleans": {`false`, `true`, []string{`expected boolean at '$' to be true but was false`}}, 41 | "floats": {`12.45`, `1.245`, []string{`expected number at '$' to be '1.2450000' but was '12.4500000'`}}, 42 | "ints": {`1245`, `-1245`, []string{`expected number at '$' to be '-1245.0000000' but was '1245.0000000'`}}, 43 | "strings": {`"hello"`, `"world"`, []string{`expected string at '$' to be 'world' but was 'hello'`}}, 44 | "empty v non-empty string": {`""`, `"world"`, []string{`expected string at '$' to be 'world' but was ''`}}, 45 | } { 46 | t.Run(name, func(t *testing.T) { tc.check(t) }) 47 | } 48 | }) 49 | }) 50 | 51 | t.Run("objects", func(t *testing.T) { 52 | t.Parallel() 53 | t.Run("flat", func(t *testing.T) { 54 | t.Parallel() 55 | for name, tc := range map[string]*testCase{ 56 | "identical objects": { 57 | `{"hello": "world"}`, 58 | `{"hello":"world"}`, 59 | nil, 60 | }, 61 | "empty v non-empty object": { 62 | `{}`, 63 | `{"a": "b"}`, 64 | []string{ 65 | `expected 1 keys at '$' but got 0 keys`, 66 | `expected object key(s) ["a"] missing at '$'`, 67 | }, 68 | }, 69 | "different values in objects": { 70 | `{"foo": "hello"}`, 71 | `{"foo": "world" }`, 72 | []string{`expected string at '$.foo' to be 'world' but was 'hello'`}, 73 | }, 74 | "different keys in objects": { 75 | `{"world": "hello"}`, 76 | `{"hello":"world"}`, 77 | []string{ 78 | `unexpected object key(s) ["world"] found at '$'`, 79 | `expected object key(s) ["hello"] missing at '$'`, 80 | }, 81 | }, 82 | } { 83 | t.Run(name, func(t *testing.T) { tc.check(t) }) 84 | } 85 | }) 86 | 87 | t.Run("nested", func(t *testing.T) { 88 | t.Parallel() 89 | for name, tc := range map[string]*testCase{ 90 | "different keys in nested objects": { 91 | `{"foo": {"world": "hello"}}`, 92 | `{"foo": {"hello": "world"}}`, 93 | []string{ 94 | `unexpected object key(s) ["world"] found at '$.foo'`, 95 | `expected object key(s) ["hello"] missing at '$.foo'`, 96 | }, 97 | }, 98 | "different values in nested objects": { 99 | `{"foo": {"hello": "world"}}`, 100 | `{"foo": {"hello":"世界"}}`, 101 | []string{`expected string at '$.foo.hello' to be '世界' but was 'world'`}, 102 | }, 103 | "only one object is nested": { 104 | `{}`, 105 | `{ "foo": { "hello": "世界" } }`, 106 | []string{ 107 | `expected 1 keys at '$' but got 0 keys`, 108 | `expected object key(s) ["foo"] missing at '$'`, 109 | }, 110 | }, 111 | } { 112 | t.Run(name, func(t *testing.T) { tc.check(t) }) 113 | } 114 | }) 115 | 116 | t.Run("with PRESENCE directives", func(t *testing.T) { 117 | t.Parallel() 118 | for name, tc := range map[string]*testCase{ 119 | "presence against null": { 120 | `{"foo": null}`, 121 | `{"foo": "<>"}`, 122 | []string{`expected the presence of any value at '$.foo', but was absent`}, 123 | }, 124 | "presence against boolean": { 125 | `{"foo": true}`, 126 | `{"foo": "<>"}`, 127 | nil, 128 | }, 129 | "presence against number": { 130 | `{"foo": 1234}`, 131 | `{"foo": "<>"}`, 132 | nil, 133 | }, 134 | "presence against string": { 135 | `{"foo": "hello world"}`, 136 | `{"foo": "<>"}`, 137 | nil, 138 | }, 139 | "presence against object": { 140 | `{"foo": {"bar": "baz"}}`, 141 | `{"foo": "<>"}`, 142 | nil, 143 | }, 144 | "presence against array": { 145 | `{"foo": ["bar", "baz"]}`, 146 | `{"foo": "<>"}`, 147 | nil, 148 | }, 149 | } { 150 | t.Run(name, func(t *testing.T) { tc.check(t) }) 151 | } 152 | }) 153 | }) 154 | 155 | t.Run("arrays", func(t *testing.T) { 156 | t.Parallel() 157 | t.Run("flat", func(t *testing.T) { 158 | t.Parallel() 159 | for name, tc := range map[string]*testCase{ 160 | "empty array v empty array": { 161 | `[]`, 162 | `[ ]`, 163 | nil, 164 | }, 165 | "non-empty array v empty array": { 166 | `[null]`, 167 | `[ ]`, 168 | []string{ 169 | `length of arrays at '$' were different. Expected array to be of length 0, but contained 1 element(s)`, 170 | `actual JSON at '$' was: [null], but expected JSON was: []`, 171 | }, 172 | }, 173 | "non-empty array v different non-empty array": { 174 | `[1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0]`, 175 | `[1,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0]`, 176 | []string{ 177 | `length of arrays at '$' were different. Expected array to be of length 22, but contained 30 element(s)`, 178 | `actual JSON at '$' was: 179 | [1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0] 180 | but expected JSON was: 181 | [1,0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5,6,7,8,9,0]`, 182 | }, 183 | }, 184 | "identical non-empty arrays": { 185 | `["hello"]`, 186 | `["hello"]`, 187 | nil, 188 | }, 189 | "different non-empty arrays": { 190 | `["hello"]`, 191 | `["world"]`, 192 | []string{`expected string at '$[0]' to be 'world' but was 'hello'`}, 193 | }, 194 | "different length non-empty arrays": { 195 | `["hello", "world"]`, 196 | `["world"]`, 197 | []string{ 198 | `length of arrays at '$' were different. Expected array to be of length 1, but contained 2 element(s)`, 199 | `actual JSON at '$' was: ["hello","world"], but expected JSON was: ["world"]`, 200 | }, 201 | }, 202 | } { 203 | t.Run(name, func(t *testing.T) { tc.check(t) }) 204 | } 205 | }) 206 | 207 | t.Run("composite elements", func(t *testing.T) { 208 | t.Parallel() 209 | for name, tc := range map[string]*testCase{ 210 | "single object with different values": { 211 | `[{"hello": "world"}]`, 212 | `[{"hello": "世界"}]`, 213 | []string{`expected string at '$[0].hello' to be '世界' but was 'world'`}, 214 | }, 215 | "multiple nested object with different values": { 216 | `[ 217 | {"hello": "world"}, 218 | {"foo": {"bar": "baz"}} 219 | ]`, 220 | `[ 221 | {"hello": "世界"}, 222 | {"foo": {"bat": "baz"}} 223 | ]`, 224 | []string{ 225 | `expected string at '$[0].hello' to be '世界' but was 'world'`, 226 | `unexpected object key(s) ["bar"] found at '$[1].foo'`, 227 | `expected object key(s) ["bat"] missing at '$[1].foo'`, 228 | }, 229 | }, 230 | "array as array element": { 231 | `[["hello", "world"]]`, 232 | `[["hello", "世界"]]`, 233 | []string{`expected string at '$[0][1]' to be '世界' but was 'world'`}, 234 | }, 235 | "multiple array elements": { 236 | `[["hello", "world"], [["foo"], "barz"]]`, 237 | `[["hello", "世界"], [["food"], "barz"]]`, 238 | []string{ 239 | `expected string at '$[0][1]' to be '世界' but was 'world'`, 240 | `expected string at '$[1][0][0]' to be 'food' but was 'foo'`, 241 | }, 242 | }, 243 | } { 244 | t.Run(name, func(t *testing.T) { tc.check(t) }) 245 | } 246 | }) 247 | 248 | t.Run("with UNORDERED directive", func(t *testing.T) { 249 | t.Parallel() 250 | for name, tc := range map[string]*testCase{ 251 | "no elements": {`[]`, `["<>"]`, nil}, 252 | "only one equal element": {`["foo"]`, `["<>", "foo"]`, nil}, 253 | "two elements ordered": { 254 | `["foo", "bar"]`, 255 | `["<>", "foo", "bar"]`, 256 | nil, 257 | }, 258 | "two elements unordered": { 259 | `["bar", "foo"]`, 260 | `["<>", "foo", "bar"]`, 261 | nil, 262 | }, 263 | "different number of elements": { 264 | `["foo"]`, 265 | `["<>", "foo", "bar"]`, 266 | []string{ 267 | `length of arrays at '$' were different. Expected array to be of length 2, but contained 1 element(s)`, 268 | `actual JSON at '$' was: ["foo"], but expected JSON was: ["foo","bar"], potentially in a different order`, 269 | }, 270 | }, 271 | "two different elements": { 272 | `["far", "boo"]`, 273 | `["<>", "foo", "bar"]`, 274 | []string{ 275 | `actual JSON at '$[0]' contained an unexpected element: "far"`, 276 | `actual JSON at '$[1]' contained an unexpected element: "boo"`, 277 | `expected JSON at '$[0]': "foo" was missing from actual payload`, 278 | `expected JSON at '$[1]': "bar" was missing from actual payload`, 279 | }, 280 | }, 281 | "valid array of different primitive types": { 282 | `["far", 1, null, true, [], {}]`, 283 | `["<>", true, 1, null, [], "far", {} ]`, 284 | nil, 285 | }, 286 | "duplicates should still error out": { 287 | `["foo", "boo", "foo"]`, 288 | `["<>", "foo", "boo"]`, 289 | []string{ 290 | `length of arrays at '$' were different. Expected array to be of length 2, but contained 3 element(s)`, 291 | `actual JSON at '$' was: ["foo","boo","foo"], but expected JSON was: ["foo","boo"], potentially in a different order`, 292 | }, 293 | }, 294 | "nested unordered arrays": { 295 | // really long object means that serializing it the same is 296 | // highly unlikely should the determinism of JSON 297 | // serialization go away. 298 | `[{"20": 20}, {"19": 19}, {"18": 18 }, {"17": 17 }, {"16": 16 }, {"15": 15 }, {"14": 14 }, {"13": 13 }, {"12": 12 }, {"11": 11 }, {"10": 10 }, {"9": 9 }, {"8": 8 }, {"7": 7 }, {"6": 6 }, {"5": 5 }, {"4": 4 }, {"3": 3 }, {"2": 2 }, {"1": 1}]`, 299 | `["<>", {"1": 1}, {"2": 2}, {"3": 3}, {"4": 4}, {"5": 5}, {"6": 6}, {"7": 7}, {"8": 8}, {"9": 9}, {"10": 10}, {"11": 11}, {"12": 12}, {"13": 13}, {"14": 14}, {"15": 15}, {"16": 16}, {"17": 17}, {"18": 18}, {"19": 19}, {"20": 20}]`, 300 | nil, 301 | }, 302 | "unordered array with objects with PRESENCE directives": { 303 | // Regression test for https://github.com/kinbiko/jsonassert/issues/39 304 | `{ "data": [ { "foo": 1, "bar": 2 }, { "foo": 11, "bar": 22 } ] }`, 305 | `{ "data": [ "<>", { "foo": 11, "bar": "<>" }, { "foo": 1, "bar": "<>" } ] }`, 306 | nil, 307 | }, 308 | } { 309 | t.Run(name, func(t *testing.T) { tc.check(t) }) 310 | } 311 | }) 312 | }) 313 | 314 | t.Run("extra long strings should be formatted on a new line", func(t *testing.T) { 315 | t.Parallel() 316 | for name, tc := range map[string]*testCase{ 317 | "simple test string": { 318 | `"lorem ipsum dolor sit amet lorem ipsum dolor sit amet"`, 319 | `"lorem ipsum dolor sit amet lorem ipsum dolor sit amet why do I have to be the test string?"`, 320 | []string{ 321 | `expected string at '$' to be 322 | 'lorem ipsum dolor sit amet lorem ipsum dolor sit amet why do I have to be the test string?' 323 | but was 324 | 'lorem ipsum dolor sit amet lorem ipsum dolor sit amet'`, 325 | }, 326 | }, 327 | "nested unordered arrays": { 328 | `["lorem ipsum dolor sit amet lorem ipsum dolor sit amet", "lorem ipsum dolor sit amet lorem ipsum dolor sit amet"]`, 329 | `["<>", "lorem ipsum dolor sit amet lorem ipsum dolor sit amet why do I have to be the test string?"]`, 330 | []string{ 331 | `length of arrays at '$' were different. Expected array to be of length 1, but contained 2 element(s)`, 332 | `actual JSON at '$' was: 333 | ["lorem ipsum dolor sit amet lorem ipsum dolor sit amet","lorem ipsum dolor sit amet lorem ipsum dolor sit amet"] 334 | but expected JSON was: 335 | ["lorem ipsum dolor sit amet lorem ipsum dolor sit amet why do I have to be the test string?"], 336 | potentially in a different order`, 337 | }, 338 | }, 339 | } { 340 | t.Run(name, func(t *testing.T) { tc.check(t) }) 341 | } 342 | }) 343 | 344 | t.Run("big fat test", func(t *testing.T) { 345 | t.Parallel() 346 | var ( 347 | bigFatPayloadActual, _ = os.ReadFile("testdata/big-fat-payload-actual.json") 348 | bigFatPayloadExpected, _ = os.ReadFile("testdata/big-fat-payload-expected.json") 349 | ) 350 | 351 | tc := testCase{ 352 | act: fmt.Sprintf(`{ 353 | "null": null, 354 | "emptyObject": {}, 355 | "emptyArray": [], 356 | "emptyString": "", 357 | "zero": 0, 358 | "boolean": false, 359 | "positiveInt": 125, 360 | "negativeInt": -1245, 361 | "positiveFloats": 12.45, 362 | "negativeFloats": -12.345, 363 | "strings": "hello 世界", 364 | "flatArray": ["foo", "bar", "baz"], 365 | "flatObject": {"boo": "far", "biz": "qwerboipqwerb"}, 366 | "nestedArray": ["boop", ["poob", {"bat": "boi", "asdf": 14, "oi": ["boy"]}], {"n": null}], 367 | "nestedObject": %s 368 | }`, string(bigFatPayloadActual)), 369 | exp: fmt.Sprintf(`{ 370 | "nil": null, 371 | "emptyObject": [], 372 | "emptyArray": [null], 373 | "emptyString": " ", 374 | "zero": 0.00001, 375 | "boolean": true, 376 | "positiveInt": 124, 377 | "negativeInt": -1246, 378 | "positiveFloats": 11.45, 379 | "negativeFloats": -13.345, 380 | "strings": "hello world", 381 | "flatArray": ["fo", "ar", "baz"], 382 | "flatObject": {"bo": "far", "biz": "qwerboipqwer"}, 383 | "nestedArray": ["oop", ["pob", {"bat": "oi", "asdf": 13, "oi": ["by"]}], {"m": null}], 384 | "nestedObject": %s 385 | }`, string(bigFatPayloadExpected)), 386 | msgs: []string{ 387 | `unexpected object key(s) ["null"] found at '$'`, 388 | `expected object key(s) ["nil"] missing at '$'`, 389 | 390 | `actual JSON (object) and expected JSON (array) were of different types at '$.emptyObject'`, 391 | 392 | `length of arrays at '$.emptyArray' were different. Expected array to be of length 1, but contained 0 element(s)`, 393 | `actual JSON at '$.emptyArray' was: [], but expected JSON was: [null]`, 394 | 395 | `expected string at '$.emptyString' to be ' ' but was ''`, 396 | 397 | `expected number at '$.zero' to be '0.0000100' but was '0.0000000'`, 398 | 399 | `expected boolean at '$.boolean' to be true but was false`, 400 | 401 | `expected number at '$.positiveInt' to be '124.0000000' but was '125.0000000'`, 402 | 403 | `expected number at '$.negativeInt' to be '-1246.0000000' but was '-1245.0000000'`, 404 | 405 | `expected number at '$.positiveFloats' to be '11.4500000' but was '12.4500000'`, 406 | 407 | `expected number at '$.negativeFloats' to be '-13.3450000' but was '-12.3450000'`, 408 | 409 | `expected string at '$.strings' to be 'hello world' but was 'hello 世界'`, 410 | 411 | `expected string at '$.flatArray[0]' to be 'fo' but was 'foo'`, 412 | `expected string at '$.flatArray[1]' to be 'ar' but was 'bar'`, 413 | 414 | `unexpected object key(s) ["boo"] found at '$.flatObject'`, 415 | `expected object key(s) ["bo"] missing at '$.flatObject'`, 416 | `expected string at '$.flatObject.biz' to be 'qwerboipqwer' but was 'qwerboipqwerb'`, 417 | 418 | `expected string at '$.nestedArray[0]' to be 'oop' but was 'boop'`, 419 | `expected string at '$.nestedArray[1][0]' to be 'pob' but was 'poob'`, 420 | `expected number at '$.nestedArray[1][1].asdf' to be '13.0000000' but was '14.0000000'`, 421 | `expected string at '$.nestedArray[1][1].bat' to be 'oi' but was 'boi'`, 422 | `expected string at '$.nestedArray[1][1].oi[0]' to be 'by' but was 'boy'`, 423 | `unexpected object key(s) ["n"] found at '$.nestedArray[2]'`, 424 | `expected object key(s) ["m"] missing at '$.nestedArray[2]'`, 425 | 426 | `expected boolean at '$.nestedObject.is_full_report' to be false but was true`, 427 | `expected string at '$.nestedObject.id' to be 's869n10s9000060596qs3007' but was 's869n10s9000060s96qs3007'`, 428 | `actual JSON (object) and expected JSON (null) were of different types at '$.nestedObject.request.headers'`, 429 | `expected string at '$.nestedObject.metaData.device.userAgent' to be 430 | 'Mozilla/4.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' 431 | but was 432 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36'`, 433 | `expected 7 keys at '$.nestedObject.source_map_failure' but got 8 keys`, 434 | `unexpected object key(s) ["source_map_url"] found at '$.nestedObject.source_map_failure'`, 435 | `expected boolean at '$.nestedObject.source_map_failure.has_uploaded_source_maps_for_version' to be true but was false`, 436 | `actual JSON at '$.nestedObject.breadcrumbs[1]' contained an unexpected element: 437 | "Something that is most definitely missing from the expected one, right??"`, 438 | `expected JSON at '$.nestedObject.breadcrumbs[1]': 439 | "Something that is most definitely missing from the actual one, right??" 440 | was missing from actual payload`, 441 | }, 442 | } 443 | tc.check(t) 444 | }) 445 | } 446 | 447 | type testCase struct { 448 | act, exp string 449 | msgs []string 450 | } 451 | 452 | func (tc *testCase) check(t *testing.T) { 453 | t.Helper() 454 | tp := &testPrinter{messages: nil} 455 | jsonassert.New(tp).Assert(tc.act, tc.exp) 456 | 457 | if got := len(tp.messages); got != len(tc.msgs) { 458 | t.Errorf("expected %d assertion message(s) but got %d", len(tc.msgs), got) 459 | } 460 | 461 | for _, expMsg := range tc.msgs { 462 | found := false 463 | for _, printedMsg := range tp.messages { 464 | found = found || expMsg == printedMsg 465 | } 466 | if !found { 467 | t.Errorf("missing assertion message:\n%s", expMsg) 468 | } 469 | } 470 | 471 | for _, printedMsg := range tp.messages { 472 | found := false 473 | for _, expMsg := range tc.msgs { 474 | found = found || printedMsg == expMsg 475 | } 476 | if !found { 477 | t.Errorf("unexpected assertion message:\n%s", printedMsg) 478 | } 479 | } 480 | } 481 | 482 | type testPrinter struct { 483 | messages []string 484 | } 485 | 486 | func (tp *testPrinter) Errorf(msg string, args ...interface{}) { 487 | tp.messages = append(tp.messages, fmt.Sprintf(msg, args...)) 488 | } 489 | 490 | func (tp *testPrinter) Helper() { 491 | // Do nothing in tests 492 | } 493 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kinbiko/jsonassert/689128796b01dbc5c1e7df7097896b55ac27a490/logo.png -------------------------------------------------------------------------------- /number.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | ) 7 | 8 | // This is *probably* good enough. Can change this to be even smaller if necessary 9 | const ( 10 | minDiff = 0.000001 11 | bitSize = 64 12 | ) 13 | 14 | func (a *Asserter) checkNumber(path string, act, exp float64) { 15 | a.tt.Helper() 16 | if diff := math.Abs(act - exp); diff > minDiff { 17 | a.tt.Errorf("expected number at '%s' to be '%.7f' but was '%.7f'", path, exp, act) 18 | } 19 | } 20 | 21 | func extractNumber(n string) (float64, bool) { 22 | got, err := strconv.ParseFloat(n, bitSize) 23 | return got, err == nil 24 | } 25 | -------------------------------------------------------------------------------- /object.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | func (a *Asserter) checkObject(path string, act, exp map[string]interface{}) { 9 | a.tt.Helper() 10 | if len(act) != len(exp) { 11 | a.tt.Errorf("expected %d keys at '%s' but got %d keys", len(exp), path, len(act)) 12 | } 13 | if unique := difference(act, exp); len(unique) != 0 { 14 | a.tt.Errorf("unexpected object key(s) %+v found at '%s'", serialize(unique), path) 15 | } 16 | if unique := difference(exp, act); len(unique) != 0 { 17 | a.tt.Errorf("expected object key(s) %+v missing at '%s'", serialize(unique), path) 18 | } 19 | for key := range act { 20 | if contains(exp, key) { 21 | a.pathassertf(path+"."+key, serialize(act[key]), serialize(exp[key])) 22 | } 23 | } 24 | } 25 | 26 | func difference(act, exp map[string]interface{}) []string { 27 | unique := []string{} 28 | for key := range act { 29 | if !contains(exp, key) { 30 | unique = append(unique, key) 31 | } 32 | } 33 | return unique 34 | } 35 | 36 | func contains(container map[string]interface{}, candidate string) bool { 37 | for key := range container { 38 | if key == candidate { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | 45 | func extractObject(s string) (map[string]interface{}, bool) { 46 | s = strings.TrimSpace(s) 47 | if s == "" { 48 | return nil, false 49 | } 50 | var arr map[string]interface{} 51 | return arr, json.Unmarshal([]byte(s), &arr) == nil 52 | } 53 | -------------------------------------------------------------------------------- /string.go: -------------------------------------------------------------------------------- 1 | package jsonassert 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | func (a *Asserter) checkString(path, act, exp string) { 9 | a.tt.Helper() 10 | if act != exp { 11 | if len(exp+act) < maxMsgCharCount { 12 | a.tt.Errorf("expected string at '%s' to be '%s' but was '%s'", path, exp, act) 13 | } else { 14 | a.tt.Errorf("expected string at '%s' to be\n'%s'\nbut was\n'%s'", path, exp, act) 15 | } 16 | } 17 | } 18 | 19 | func extractString(s string) (string, bool) { 20 | s = strings.TrimSpace(s) 21 | if s == "" { 22 | return "", false 23 | } 24 | if s[0] != '"' { 25 | return "", false 26 | } 27 | var str string 28 | return str, json.Unmarshal([]byte(s), &str) == nil 29 | } 30 | -------------------------------------------------------------------------------- /testdata/big-fat-payload-actual.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "s869n10s9000060s96qs3007", 3 | "is_full_report": true, 4 | "error_id": "60f96df393459c000789707e", 5 | "received_at": "2021-07-22T13:09:07.029Z", 6 | "exceptions": [ 7 | { 8 | "error_class": "ReferenceError", 9 | "message": "setLoggedOut is not defined", 10 | "type": "browserjs", 11 | "stacktrace": [ 12 | { 13 | "column_number": 19, 14 | "in_project": null, 15 | "line_number": 349, 16 | "method": "App", 17 | "file": "http://localhost:3000/static/js/main.chunk.js", 18 | "type": null, 19 | "code": null, 20 | "code_file": null, 21 | "address_offset": null, 22 | "macho_uuid": null, 23 | "source_control_link": null, 24 | "source_control_name": "" 25 | }, 26 | { 27 | "column_number": 22, 28 | "in_project": null, 29 | "line_number": 24476, 30 | "method": "renderWithHooks", 31 | "file": "http://localhost:3000/static/js/1.chunk.js", 32 | "type": null, 33 | "code": null, 34 | "code_file": null, 35 | "address_offset": null, 36 | "macho_uuid": null, 37 | "source_control_link": null, 38 | "source_control_name": "" 39 | }, 40 | { 41 | "column_number": 17, 42 | "in_project": null, 43 | "line_number": 27087, 44 | "method": "mountIndeterminateComponent", 45 | "file": "http://localhost:3000/static/js/1.chunk.js", 46 | "type": null, 47 | "code": null, 48 | "code_file": null, 49 | "address_offset": null, 50 | "macho_uuid": null, 51 | "source_control_link": null, 52 | "source_control_name": "" 53 | }, 54 | { 55 | "column_number": 20, 56 | "in_project": null, 57 | "line_number": 28166, 58 | "method": "beginWork", 59 | "file": "http://localhost:3000/static/js/1.chunk.js", 60 | "type": null, 61 | "code": null, 62 | "code_file": null, 63 | "address_offset": null, 64 | "macho_uuid": null, 65 | "source_control_link": null, 66 | "source_control_name": "" 67 | }, 68 | { 69 | "column_number": 18, 70 | "in_project": null, 71 | "line_number": 9869, 72 | "method": "HTMLUnknownElement.callCallback", 73 | "file": "http://localhost:3000/static/js/1.chunk.js", 74 | "type": null, 75 | "code": null, 76 | "code_file": null, 77 | "address_offset": null, 78 | "macho_uuid": null, 79 | "source_control_link": null, 80 | "source_control_name": "" 81 | }, 82 | { 83 | "column_number": 20, 84 | "in_project": null, 85 | "line_number": 9918, 86 | "method": "Object.invokeGuardedCallbackDev", 87 | "file": "http://localhost:3000/static/js/1.chunk.js", 88 | "type": null, 89 | "code": null, 90 | "code_file": null, 91 | "address_offset": null, 92 | "macho_uuid": null, 93 | "source_control_link": null, 94 | "source_control_name": "" 95 | }, 96 | { 97 | "column_number": 35, 98 | "in_project": null, 99 | "line_number": 9971, 100 | "method": "invokeGuardedCallback", 101 | "file": "http://localhost:3000/static/js/1.chunk.js", 102 | "type": null, 103 | "code": null, 104 | "code_file": null, 105 | "address_offset": null, 106 | "macho_uuid": null, 107 | "source_control_link": null, 108 | "source_control_name": "" 109 | }, 110 | { 111 | "column_number": 11, 112 | "in_project": null, 113 | "line_number": 32732, 114 | "method": "beginWork$1", 115 | "file": "http://localhost:3000/static/js/1.chunk.js", 116 | "type": null, 117 | "code": null, 118 | "code_file": "1.chunk.js", 119 | "address_offset": null, 120 | "macho_uuid": null, 121 | "source_control_link": null, 122 | "source_control_name": "" 123 | }, 124 | { 125 | "column_number": 16, 126 | "in_project": null, 127 | "line_number": 31696, 128 | "method": "performUnitOfWork", 129 | "file": "http://localhost:3000/static/js/1.chunk.js", 130 | "type": null, 131 | "code": null, 132 | "code_file": null, 133 | "address_offset": null, 134 | "macho_uuid": null, 135 | "source_control_link": null, 136 | "source_control_name": "" 137 | }, 138 | { 139 | "column_number": 26, 140 | "in_project": null, 141 | "line_number": 31672, 142 | "method": "workLoopSync", 143 | "file": "http://localhost:3000/static/js/1.chunk.js", 144 | "type": null, 145 | "code": null, 146 | "code_file": null, 147 | "address_offset": null, 148 | "macho_uuid": null, 149 | "source_control_link": null, 150 | "source_control_name": "" 151 | }, 152 | { 153 | "column_number": 13, 154 | "in_project": null, 155 | "line_number": 31290, 156 | "method": "performSyncWorkOnRoot", 157 | "file": "http://localhost:3000/static/js/1.chunk.js", 158 | "type": null, 159 | "code": null, 160 | "code_file": null, 161 | "address_offset": null, 162 | "macho_uuid": null, 163 | "source_control_link": null, 164 | "source_control_name": "" 165 | }, 166 | { 167 | "column_number": 11, 168 | "in_project": null, 169 | "line_number": 30722, 170 | "method": "scheduleUpdateOnFiber", 171 | "file": "http://localhost:3000/static/js/1.chunk.js", 172 | "type": null, 173 | "code": null, 174 | "code_file": null, 175 | "address_offset": null, 176 | "macho_uuid": null, 177 | "source_control_link": null, 178 | "source_control_name": "" 179 | }, 180 | { 181 | "column_number": 7, 182 | "in_project": null, 183 | "line_number": 33871, 184 | "method": "updateContainer", 185 | "file": "http://localhost:3000/static/js/1.chunk.js", 186 | "type": null, 187 | "code": null, 188 | "code_file": null, 189 | "address_offset": null, 190 | "macho_uuid": null, 191 | "source_control_link": null, 192 | "source_control_name": "" 193 | }, 194 | { 195 | "column_number": 11, 196 | "in_project": null, 197 | "line_number": 34254, 198 | "method": "", 199 | "file": "http://localhost:3000/static/js/1.chunk.js", 200 | "type": null, 201 | "code": null, 202 | "code_file": null, 203 | "address_offset": null, 204 | "macho_uuid": null, 205 | "source_control_link": null, 206 | "source_control_name": "" 207 | }, 208 | { 209 | "column_number": 16, 210 | "in_project": null, 211 | "line_number": 31440, 212 | "method": "unbatchedUpdates", 213 | "file": "http://localhost:3000/static/js/1.chunk.js", 214 | "type": null, 215 | "code": null, 216 | "code_file": null, 217 | "address_offset": null, 218 | "macho_uuid": null, 219 | "source_control_link": null, 220 | "source_control_name": "" 221 | }, 222 | { 223 | "column_number": 9, 224 | "in_project": null, 225 | "line_number": 34253, 226 | "method": "legacyRenderSubtreeIntoContainer", 227 | "file": "http://localhost:3000/static/js/1.chunk.js", 228 | "type": null, 229 | "code": null, 230 | "code_file": null, 231 | "address_offset": null, 232 | "macho_uuid": null, 233 | "source_control_link": null, 234 | "source_control_name": "" 235 | }, 236 | { 237 | "column_number": 14, 238 | "in_project": null, 239 | "line_number": 34336, 240 | "method": "Object.render", 241 | "file": "http://localhost:3000/static/js/1.chunk.js", 242 | "type": null, 243 | "code": null, 244 | "code_file": null, 245 | "address_offset": null, 246 | "macho_uuid": null, 247 | "source_control_link": null, 248 | "source_control_name": "" 249 | }, 250 | { 251 | "column_number": 50, 252 | "in_project": null, 253 | "line_number": 1075, 254 | "method": "Module../src/index.tsx", 255 | "file": "http://localhost:3000/static/js/main.chunk.js", 256 | "type": null, 257 | "code": null, 258 | "code_file": null, 259 | "address_offset": null, 260 | "macho_uuid": null, 261 | "source_control_link": null, 262 | "source_control_name": "" 263 | }, 264 | { 265 | "column_number": 30, 266 | "in_project": null, 267 | "line_number": 785, 268 | "method": "__webpack_require__", 269 | "file": "http://localhost:3000/static/js/bundle.js", 270 | "type": null, 271 | "code": null, 272 | "code_file": null, 273 | "address_offset": null, 274 | "macho_uuid": null, 275 | "source_control_link": null, 276 | "source_control_name": "" 277 | }, 278 | { 279 | "column_number": 20, 280 | "in_project": null, 281 | "line_number": 151, 282 | "method": "fn", 283 | "file": "http://localhost:3000/static/js/bundle.js", 284 | "type": null, 285 | "code": null, 286 | "code_file": null, 287 | "address_offset": null, 288 | "macho_uuid": null, 289 | "source_control_link": null, 290 | "source_control_name": "" 291 | }, 292 | { 293 | "column_number": 18, 294 | "in_project": null, 295 | "line_number": 1088, 296 | "method": "Object.1", 297 | "file": "http://localhost:3000/static/js/main.chunk.js", 298 | "type": null, 299 | "code": null, 300 | "code_file": null, 301 | "address_offset": null, 302 | "macho_uuid": null, 303 | "source_control_link": null, 304 | "source_control_name": "" 305 | }, 306 | { 307 | "column_number": 30, 308 | "in_project": null, 309 | "line_number": 785, 310 | "method": "__webpack_require__", 311 | "file": "http://localhost:3000/static/js/bundle.js", 312 | "type": null, 313 | "code": null, 314 | "code_file": null, 315 | "address_offset": null, 316 | "macho_uuid": null, 317 | "source_control_link": null, 318 | "source_control_name": "" 319 | }, 320 | { 321 | "column_number": 23, 322 | "in_project": null, 323 | "line_number": 46, 324 | "method": "checkDeferredModules", 325 | "file": "http://localhost:3000/static/js/bundle.js", 326 | "type": null, 327 | "code": null, 328 | "code_file": null, 329 | "address_offset": null, 330 | "macho_uuid": null, 331 | "source_control_link": null, 332 | "source_control_name": "" 333 | }, 334 | { 335 | "column_number": 19, 336 | "in_project": null, 337 | "line_number": 33, 338 | "method": "Array.webpackJsonpCallback [as push]", 339 | "file": "http://localhost:3000/static/js/bundle.js", 340 | "type": null, 341 | "code": null, 342 | "code_file": null, 343 | "address_offset": null, 344 | "macho_uuid": null, 345 | "source_control_link": null, 346 | "source_control_name": "" 347 | }, 348 | { 349 | "column_number": 59, 350 | "in_project": null, 351 | "line_number": 1, 352 | "method": "", 353 | "file": "http://localhost:3000/static/js/main.chunk.js", 354 | "type": null, 355 | "code": null, 356 | "code_file": null, 357 | "address_offset": null, 358 | "macho_uuid": null, 359 | "source_control_link": null, 360 | "source_control_name": "" 361 | } 362 | ], 363 | "registers": null 364 | } 365 | ], 366 | "threads": null, 367 | "metaData": { 368 | "device": { 369 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36" 370 | } 371 | }, 372 | "request": { 373 | "url": "http://localhost:3000/", 374 | "clientIp": "27.143.62.164", 375 | "headers": {} 376 | }, 377 | "app": { "releaseStage": "development", "duration": 98 }, 378 | "device": { 379 | "osName": "Mac OS X 10.15", 380 | "browserName": "Chrome", 381 | "browserVersion": "91.0.4472", 382 | "orientation": "landscape-primary", 383 | "locale": "en-GB", 384 | "time": "2021-07-22T13:09:06.555Z" 385 | }, 386 | "user": { "id": "27.143.62.164" }, 387 | "breadcrumbs": [ 388 | { 389 | "timestamp": "2021-07-22T13:09:06.526Z", 390 | "name": "Bugsnag loaded", 391 | "type": "navigation", 392 | "metaData": {} 393 | }, 394 | "Something that is most definitely missing from the expected one, right??" 395 | ], 396 | "context": "/", 397 | "severity": "error", 398 | "unhandled": true, 399 | "incomplete": false, 400 | "overridden_severity": null, 401 | "severity_reason": { "type": "handledException" }, 402 | "source_map_failure": { 403 | "reason": "missing-js", 404 | "has_uploaded_source_maps_for_project": false, 405 | "has_uploaded_source_maps_for_version": false, 406 | "is_local_minified_url": true, 407 | "source_map_url": null, 408 | "file_url": "http://localhost:3000/static/js/main.chunk.js", 409 | "platform": null, 410 | "release_variant": null 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /testdata/big-fat-payload-expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "s869n10s9000060596qs3007", 3 | "is_full_report": false, 4 | "error_id": "60f96df393459c000789707e", 5 | "received_at": "2021-07-22T13:09:07.029Z", 6 | "exceptions": [ 7 | { 8 | "error_class": "ReferenceError", 9 | "message": "setLoggedOut is not defined", 10 | "type": "browserjs", 11 | "stacktrace": [ 12 | "<>", 13 | { 14 | "column_number": 19, 15 | "in_project": null, 16 | "line_number": 349, 17 | "method": "App", 18 | "file": "http://localhost:3000/static/js/main.chunk.js", 19 | "type": null, 20 | "code": null, 21 | "code_file": null, 22 | "address_offset": null, 23 | "macho_uuid": null, 24 | "source_control_link": null, 25 | "source_control_name": "" 26 | }, 27 | { 28 | "column_number": 22, 29 | "in_project": null, 30 | "line_number": 24476, 31 | "method": "renderWithHooks", 32 | "file": "http://localhost:3000/static/js/1.chunk.js", 33 | "type": null, 34 | "code": null, 35 | "code_file": null, 36 | "address_offset": null, 37 | "macho_uuid": null, 38 | "source_control_link": null, 39 | "source_control_name": "" 40 | }, 41 | { 42 | "column_number": 20, 43 | "in_project": null, 44 | "line_number": 28166, 45 | "method": "beginWork", 46 | "file": "http://localhost:3000/static/js/1.chunk.js", 47 | "type": null, 48 | "code": null, 49 | "code_file": null, 50 | "address_offset": null, 51 | "macho_uuid": null, 52 | "source_control_link": null, 53 | "source_control_name": "" 54 | }, 55 | { 56 | "column_number": 18, 57 | "in_project": null, 58 | "line_number": 9869, 59 | "method": "HTMLUnknownElement.callCallback", 60 | "file": "http://localhost:3000/static/js/1.chunk.js", 61 | "type": null, 62 | "code": null, 63 | "code_file": null, 64 | "address_offset": null, 65 | "macho_uuid": null, 66 | "source_control_link": null, 67 | "source_control_name": "" 68 | }, 69 | { 70 | "column_number": 17, 71 | "in_project": null, 72 | "line_number": 27087, 73 | "method": "mountIndeterminateComponent", 74 | "file": "http://localhost:3000/static/js/1.chunk.js", 75 | "type": null, 76 | "code": null, 77 | "code_file": null, 78 | "address_offset": null, 79 | "macho_uuid": null, 80 | "source_control_link": null, 81 | "source_control_name": "" 82 | }, 83 | { 84 | "column_number": 20, 85 | "in_project": null, 86 | "line_number": 9918, 87 | "method": "Object.invokeGuardedCallbackDev", 88 | "file": "http://localhost:3000/static/js/1.chunk.js", 89 | "type": null, 90 | "code": null, 91 | "code_file": null, 92 | "address_offset": null, 93 | "macho_uuid": null, 94 | "source_control_link": null, 95 | "source_control_name": "" 96 | }, 97 | { 98 | "column_number": 35, 99 | "in_project": null, 100 | "line_number": 9971, 101 | "method": "invokeGuardedCallback", 102 | "file": "http://localhost:3000/static/js/1.chunk.js", 103 | "type": null, 104 | "code": null, 105 | "code_file": null, 106 | "address_offset": null, 107 | "macho_uuid": null, 108 | "source_control_link": null, 109 | "source_control_name": "" 110 | }, 111 | { 112 | "column_number": 11, 113 | "in_project": null, 114 | "line_number": 32732, 115 | "method": "beginWork$1", 116 | "file": "http://localhost:3000/static/js/1.chunk.js", 117 | "type": null, 118 | "code": null, 119 | "code_file": "1.chunk.js", 120 | "address_offset": null, 121 | "macho_uuid": null, 122 | "source_control_link": null, 123 | "source_control_name": "" 124 | }, 125 | { 126 | "column_number": 16, 127 | "in_project": null, 128 | "line_number": 31696, 129 | "method": "performUnitOfWork", 130 | "file": "http://localhost:3000/static/js/1.chunk.js", 131 | "type": null, 132 | "code": null, 133 | "code_file": null, 134 | "address_offset": null, 135 | "macho_uuid": null, 136 | "source_control_link": null, 137 | "source_control_name": "" 138 | }, 139 | { 140 | "column_number": 26, 141 | "in_project": null, 142 | "line_number": 31672, 143 | "method": "workLoopSync", 144 | "file": "http://localhost:3000/static/js/1.chunk.js", 145 | "type": null, 146 | "code": null, 147 | "code_file": null, 148 | "address_offset": null, 149 | "macho_uuid": null, 150 | "source_control_link": null, 151 | "source_control_name": "" 152 | }, 153 | { 154 | "column_number": 13, 155 | "in_project": null, 156 | "line_number": 31290, 157 | "method": "performSyncWorkOnRoot", 158 | "file": "http://localhost:3000/static/js/1.chunk.js", 159 | "type": null, 160 | "code": null, 161 | "code_file": null, 162 | "address_offset": null, 163 | "macho_uuid": null, 164 | "source_control_link": null, 165 | "source_control_name": "" 166 | }, 167 | { 168 | "column_number": 11, 169 | "in_project": null, 170 | "line_number": 30722, 171 | "method": "scheduleUpdateOnFiber", 172 | "file": "http://localhost:3000/static/js/1.chunk.js", 173 | "type": null, 174 | "code": null, 175 | "code_file": null, 176 | "address_offset": null, 177 | "macho_uuid": null, 178 | "source_control_link": null, 179 | "source_control_name": "" 180 | }, 181 | { 182 | "column_number": 7, 183 | "in_project": null, 184 | "line_number": 33871, 185 | "method": "updateContainer", 186 | "file": "http://localhost:3000/static/js/1.chunk.js", 187 | "type": null, 188 | "code": null, 189 | "code_file": null, 190 | "address_offset": null, 191 | "macho_uuid": null, 192 | "source_control_link": null, 193 | "source_control_name": "" 194 | }, 195 | { 196 | "column_number": 11, 197 | "in_project": null, 198 | "line_number": 34254, 199 | "method": "", 200 | "file": "http://localhost:3000/static/js/1.chunk.js", 201 | "type": null, 202 | "code": null, 203 | "code_file": null, 204 | "address_offset": null, 205 | "macho_uuid": null, 206 | "source_control_link": null, 207 | "source_control_name": "" 208 | }, 209 | { 210 | "column_number": 16, 211 | "in_project": null, 212 | "line_number": 31440, 213 | "method": "unbatchedUpdates", 214 | "file": "http://localhost:3000/static/js/1.chunk.js", 215 | "type": null, 216 | "code": null, 217 | "code_file": null, 218 | "address_offset": null, 219 | "macho_uuid": null, 220 | "source_control_link": null, 221 | "source_control_name": "" 222 | }, 223 | { 224 | "column_number": 9, 225 | "in_project": null, 226 | "line_number": 34253, 227 | "method": "legacyRenderSubtreeIntoContainer", 228 | "file": "http://localhost:3000/static/js/1.chunk.js", 229 | "type": null, 230 | "code": null, 231 | "code_file": null, 232 | "address_offset": null, 233 | "macho_uuid": null, 234 | "source_control_link": null, 235 | "source_control_name": "" 236 | }, 237 | { 238 | "column_number": 14, 239 | "in_project": null, 240 | "line_number": 34336, 241 | "method": "Object.render", 242 | "file": "http://localhost:3000/static/js/1.chunk.js", 243 | "type": null, 244 | "code": null, 245 | "code_file": null, 246 | "address_offset": null, 247 | "macho_uuid": null, 248 | "source_control_link": null, 249 | "source_control_name": "" 250 | }, 251 | { 252 | "column_number": 50, 253 | "in_project": null, 254 | "line_number": 1075, 255 | "method": "Module../src/index.tsx", 256 | "file": "http://localhost:3000/static/js/main.chunk.js", 257 | "type": null, 258 | "code": null, 259 | "code_file": null, 260 | "address_offset": null, 261 | "macho_uuid": null, 262 | "source_control_link": null, 263 | "source_control_name": "" 264 | }, 265 | { 266 | "column_number": 30, 267 | "in_project": null, 268 | "line_number": 785, 269 | "method": "__webpack_require__", 270 | "file": "http://localhost:3000/static/js/bundle.js", 271 | "type": null, 272 | "code": null, 273 | "code_file": null, 274 | "address_offset": null, 275 | "macho_uuid": null, 276 | "source_control_link": null, 277 | "source_control_name": "" 278 | }, 279 | { 280 | "column_number": 20, 281 | "in_project": null, 282 | "line_number": 151, 283 | "method": "fn", 284 | "file": "http://localhost:3000/static/js/bundle.js", 285 | "type": null, 286 | "code": null, 287 | "code_file": null, 288 | "address_offset": null, 289 | "macho_uuid": null, 290 | "source_control_link": null, 291 | "source_control_name": "" 292 | }, 293 | { 294 | "column_number": 18, 295 | "in_project": null, 296 | "line_number": 1088, 297 | "method": "Object.1", 298 | "file": "http://localhost:3000/static/js/main.chunk.js", 299 | "type": null, 300 | "code": null, 301 | "code_file": null, 302 | "address_offset": null, 303 | "macho_uuid": null, 304 | "source_control_link": null, 305 | "source_control_name": "" 306 | }, 307 | { 308 | "column_number": 30, 309 | "in_project": null, 310 | "line_number": 785, 311 | "method": "__webpack_require__", 312 | "file": "http://localhost:3000/static/js/bundle.js", 313 | "type": null, 314 | "code": null, 315 | "code_file": null, 316 | "address_offset": null, 317 | "macho_uuid": null, 318 | "source_control_link": null, 319 | "source_control_name": "" 320 | }, 321 | { 322 | "column_number": 23, 323 | "in_project": null, 324 | "line_number": 46, 325 | "method": "checkDeferredModules", 326 | "file": "http://localhost:3000/static/js/bundle.js", 327 | "type": null, 328 | "code": null, 329 | "code_file": null, 330 | "address_offset": null, 331 | "macho_uuid": null, 332 | "source_control_link": null, 333 | "source_control_name": "" 334 | }, 335 | { 336 | "column_number": 19, 337 | "in_project": null, 338 | "line_number": 33, 339 | "method": "Array.webpackJsonpCallback [as push]", 340 | "file": "http://localhost:3000/static/js/bundle.js", 341 | "type": null, 342 | "code": null, 343 | "code_file": null, 344 | "address_offset": null, 345 | "macho_uuid": null, 346 | "source_control_link": null, 347 | "source_control_name": "" 348 | }, 349 | { 350 | "column_number": 59, 351 | "in_project": null, 352 | "line_number": 1, 353 | "method": "", 354 | "file": "http://localhost:3000/static/js/main.chunk.js", 355 | "type": null, 356 | "code": null, 357 | "code_file": null, 358 | "address_offset": null, 359 | "macho_uuid": null, 360 | "source_control_link": null, 361 | "source_control_name": "" 362 | } 363 | ], 364 | "registers": null 365 | } 366 | ], 367 | "threads": null, 368 | "metaData": { 369 | "device": { 370 | "userAgent": "Mozilla/4.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36" 371 | } 372 | }, 373 | "request": { 374 | "url": "http://localhost:3000/", 375 | "clientIp": "27.143.62.164", 376 | "headers": null 377 | }, 378 | "app": { "releaseStage": "development", "duration": 98 }, 379 | "device": { 380 | "osName": "Mac OS X 10.15", 381 | "browserName": "Chrome", 382 | "browserVersion": "91.0.4472", 383 | "orientation": "landscape-primary", 384 | "locale": "en-GB", 385 | "time": "2021-07-22T13:09:06.555Z" 386 | }, 387 | "user": { "id": "27.143.62.164" }, 388 | "breadcrumbs": [ 389 | "<>", 390 | { 391 | "timestamp": "2021-07-22T13:09:06.526Z", 392 | "name": "Bugsnag loaded", 393 | "type": "navigation", 394 | "metaData": {} 395 | }, 396 | "Something that is most definitely missing from the actual one, right??" 397 | ], 398 | "context": "/", 399 | "severity": "error", 400 | "unhandled": true, 401 | "incomplete": false, 402 | "overridden_severity": null, 403 | "severity_reason": { "type": "handledException" }, 404 | "source_map_failure": { 405 | "reason": "missing-js", 406 | "has_uploaded_source_maps_for_project": false, 407 | "has_uploaded_source_maps_for_version": true, 408 | "is_local_minified_url": true, 409 | "file_url": "http://localhost:3000/static/js/main.chunk.js", 410 | "platform": null, 411 | "release_variant": null 412 | } 413 | } 414 | --------------------------------------------------------------------------------