packages feed

hspec-expectations-json-1.0.2.0: library/Test/Hspec/Expectations/Json.hs

-- | Expectations on JSON 'Value's
--
-- Semantics:
--
-- +--------------------------+-------------------+-------------------+
-- | Assertion that fails on: | extra Object keys | wrong Array order +
-- +==========================+===================+===================+
-- | 'shouldBeJson'           | Yes               | Yes               |
-- +--------------------------+-------------------+-------------------+
-- | 'shouldBeUnorderedJson'  | Yes               | No                |
-- +--------------------------+-------------------+-------------------+
-- | 'shouldMatchJson'        | No                | No                |
-- +--------------------------+-------------------+-------------------+
-- | 'shouldMatchOrderedJson' | No                | Yes               |
-- +--------------------------+-------------------+-------------------+
module Test.Hspec.Expectations.Json
  ( shouldMatchJson
  , shouldBeJson
  , shouldBeJsonNormalized
  , Normalizer
  , defaultNormalizer
  , treatNullsAsMissing
  , ignoreArrayOrdering
  , subsetActualToExpected
  , expandHeterogenousArrays

    -- * Legacy API

    -- | Prefer to use shouldBeJsonNormalized with the appropriate 'Normalizer'
  , shouldBeUnorderedJson
  , shouldMatchOrderedJson

    -- * As predicates

    -- | These are only created when a specific need arises
  , matchesJson
  ) where

import Prelude

import Control.Monad (unless)
import Data.Aeson
import Data.Aeson.Encode.Pretty (encodePretty)
import Data.Bifunctor
import Data.Semigroup (Endo (..))
import Data.Text.Lazy (toStrict)
import Data.Text.Lazy.Encoding (decodeUtf8)
import GHC.Stack
import Test.Hspec.Expectations.Json.Internal
  ( Subset (..)
  , Superset (..)
  , assertBoolWithDiff
  , filterNullFields
  , normalizeScientific
  , pruneJson
  , sortJsonArrays
  )
import qualified Test.Hspec.Expectations.Json.Internal as Internal

-- $setup
-- >>> :set -XQuasiQuotes
-- >>> import Data.Aeson.QQ (aesonQQ)
-- >>> import Test.HUnit.Lang (HUnitFailure(..), formatFailureReason)
-- >>> import Control.Exception (handle)
-- >>> let printFailure (HUnitFailure _ r) = putStr $ formatFailureReason r
-- >>> let catchFailure f = handle printFailure $ f >> putStrLn "<passed>"

newtype Actual a = Actual a
  deriving (Functor)

newtype Expected a = Expected a
  deriving (Functor)

newtype Normalizer = Normalizer
  { normalize :: Endo (Actual Value, Expected Value)
  }
  deriving newtype (Semigroup, Monoid)

normalizeBoth :: (Value -> Value) -> Normalizer
normalizeBoth f = Normalizer $ Endo $ bimap (fmap f) (fmap f)

treatNullsAsMissing :: Normalizer
treatNullsAsMissing = normalizeBoth filterNullFields

ignoreArrayOrdering :: Normalizer
ignoreArrayOrdering = normalizeBoth sortJsonArrays

expandHeterogenousArrays :: Normalizer
expandHeterogenousArrays = normalizeBoth Internal.expandHeterogenousArrays

subsetActualToExpected :: Normalizer
subsetActualToExpected = Normalizer $ Endo go
 where
  go (Actual a, Expected b) =
    let a' = pruneJson (Superset a) (Subset b)
    in  (Actual a', Expected b)

defaultNormalizer :: Normalizer
defaultNormalizer =
  ignoreArrayOrdering <> subsetActualToExpected

shouldBeJsonNormalized :: HasCallStack => Normalizer -> Value -> Value -> IO ()
shouldBeJsonNormalized normalizer a b =
  unless (a == b) $
    assertBoolWithDiff (a' == b') (toText b) (toText a)
 where
  toText = toStrict . decodeUtf8 . encodePretty . normalizeScientific
  (Actual a', Expected b') = appEndo (normalize normalizer) (Actual a, Expected b)

-- | Compare two JSON values, with a useful diff
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": true, "b": false } |] `shouldBeJson`
--   [aesonQQ| { "a": true, "b": false } |]
-- :}
-- <passed>
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": true, "b": false } |] `shouldBeJson`
--   [aesonQQ| { "a": true, "b": true  } |]
-- :}
--    {
--        "a": true,
-- ---    "b": true
-- +++    "b": false
--    }
shouldBeJson :: HasCallStack => Value -> Value -> IO ()
shouldBeJson = shouldBeJsonNormalized mempty

infix 1 `shouldBeJson`

-- | 'shouldBeJson', ignoring Array ordering
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false } |] `shouldBeUnorderedJson`
--   [aesonQQ| { "a": [false, true], "b": false } |]
-- :}
-- <passed>
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false, "c": true } |] `shouldBeUnorderedJson`
--   [aesonQQ| { "a": [false, true], "b": true             } |]
-- :}
--    {
--        "a": [
--            false,
--            true
--        ],
-- ---    "b": true
-- +++    "b": false,
-- +++    "c": true
--    }
shouldBeUnorderedJson :: HasCallStack => Value -> Value -> IO ()
shouldBeUnorderedJson = shouldBeJsonNormalized ignoreArrayOrdering

infix 1 `shouldBeUnorderedJson`

-- | 'shouldBeJson', ignoring extra Object keys or Array ordering
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false, "c": true } |] `shouldMatchJson`
--   [aesonQQ| { "a": [false, true], "b": false            } |]
-- :}
-- <passed>
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false, "c": true } |] `shouldMatchJson`
--   [aesonQQ| { "a": [false, true], "b": true             } |]
-- :}
--    {
--        "a": [
--            false,
--            true
--        ],
-- ---    "b": true
-- +++    "b": false
--    }
shouldMatchJson :: HasCallStack => Value -> Value -> IO ()
shouldMatchJson = shouldBeJsonNormalized defaultNormalizer

infix 1 `shouldMatchJson`

-- | Compare JSON values with the same semantics as 'shouldMatchJson'
matchesJson :: Value -> Value -> Bool
matchesJson sup sub = sup == sub || sup' == sub'
 where
  (Actual sup', Expected sub') = appEndo (normalize defaultNormalizer) (Actual sup, Expected sub)

-- | 'shouldBeJson', ignoring extra Object keys
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false, "c": true } |] `shouldMatchOrderedJson`
--   [aesonQQ| { "a": [true, false], "b": false            } |]
-- :}
-- <passed>
--
-- >>> :{
-- catchFailure $
--   [aesonQQ| { "a": [true, false], "b": false, "c": true } |] `shouldMatchOrderedJson`
--   [aesonQQ| { "a": [false, true], "b": true             } |]
-- :}
--    {
--        "a": [
-- ---        false,
-- ---        true
-- +++        true,
-- +++        false
--        ],
-- ---    "b": true
-- +++    "b": false
--    }
shouldMatchOrderedJson :: HasCallStack => Value -> Value -> IO ()
shouldMatchOrderedJson = shouldBeJsonNormalized subsetActualToExpected

infix 1 `shouldMatchOrderedJson`