Skip to content

Commit 99aaf14

Browse files
authored
Merge pull request #418 from satorg/extactor-syntax
Propose value/effect extractor syntax
2 parents d7d6f93 + e0f52dc commit 99aaf14

File tree

2 files changed

+146
-0
lines changed

2 files changed

+146
-0
lines changed

core/src/main/scala/munit/CatsEffectAssertions.scala

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,26 @@ trait CatsEffectAssertions { self: Assertions =>
291291
Sync[F].raiseError[T](e)
292292
}
293293

294+
private def mapOrFailF[F[_], A, B](
295+
io: F[A],
296+
pf: PartialFunction[A, B],
297+
clue: => Any
298+
)(implicit F: Sync[F], loc: Location): F[B] =
299+
F.flatMap(io) { a =>
300+
// It could be just "case `pf`(b) => F.pure(b)" but 2.12 doesn't define `unapply` for `PartialFunction`.
301+
pf.andThen(F.pure[B])
302+
.applyOrElse[A, F[B]](
303+
a,
304+
aa =>
305+
F.raiseError(
306+
new FailException(
307+
s"${munitPrint(clue)}, value obtained: $aa",
308+
location = loc
309+
)
310+
)
311+
)
312+
}
313+
294314
implicit class MUnitCatsAssertionsForIOOps[A](io: IO[A]) {
295315

296316
/** Asserts that this effect returns an expected value.
@@ -335,6 +355,46 @@ trait CatsEffectAssertions { self: Assertions =>
335355
): IO[Unit] =
336356
assertIOBoolean(io.map(pred), clue)
337357

358+
/** Maps a value from this effect with a given `PartialFunction` or fails if the value doesn't
359+
* match. Then the mapped value can be used for further processing or validation.
360+
*
361+
* This method can come in handy in complex validation scenarios where multi-step assertions
362+
* are necessary.
363+
*
364+
* @example
365+
* {{{
366+
* case class Response(status: Int, body: IO[Array[Byte]])
367+
*
368+
* def decodeResponseBytes(bytes: IO[Array[Byte]]): IO[String] =
369+
* bytes.map(String.fromBytes(_))
370+
*
371+
* val response: IO[Response] =
372+
* IO.pure(Response(
373+
* status = 200,
374+
* body = IO {
375+
* "<expected response body>".getBytes("UTF-8")
376+
* }
377+
* ))
378+
*
379+
* response
380+
* // First, check if the response has the expected status,
381+
* // then pass it over to the next step for further processing.
382+
* .mapOrFail { case Response(200, body) => body }
383+
* // Decode the response body in order to prepare for the final check.
384+
* .flatMap(decodeResponseBytes)
385+
* // Make sure that the response has the expected content.
386+
* .assertEquals("<expected response body>")
387+
* }}}
388+
*
389+
* @param pf
390+
* a partial function that matches the value obtained from the effect
391+
*/
392+
def mapOrFail[B](
393+
pf: PartialFunction[A, B],
394+
clue: => Any = "value didn't match any of the defined cases"
395+
)(implicit loc: Location): IO[B] =
396+
mapOrFailF(io, pf, clue)
397+
338398
/** Intercepts a `Throwable` being thrown inside this effect.
339399
*
340400
* @example
@@ -433,6 +493,46 @@ trait CatsEffectAssertions { self: Assertions =>
433493
): SyncIO[Unit] =
434494
assertSyncIOBoolean(io.map(pred), clue)
435495

496+
/** Maps a value from this effect with a given `PartialFunction` or fails if the value doesn't
497+
* match. Then the mapped value can be used for further processing or validation.
498+
*
499+
* This method can come in handy in complex validation scenarios where multi-step assertions
500+
* are necessary.
501+
*
502+
* @example
503+
* {{{
504+
* case class Response(status: Int, body: SyncIO[Array[Byte]])
505+
*
506+
* def decodeResponseBytes(bytes: SyncIO[Array[Byte]]): SyncIO[String] =
507+
* bytes.map(String.fromBytes(_))
508+
*
509+
* val response: SyncIO[Response] =
510+
* SyncIO.pure(Response(
511+
* status = 200,
512+
* body = SyncIO {
513+
* "<expected response body>".getBytes("UTF-8")
514+
* }
515+
* ))
516+
*
517+
* response
518+
* // First, check if the response has the expected status,
519+
* // then pass it over to the next step for further processing.
520+
* .mapOrFail { case Response(200, body) => body }
521+
* // Decode the response body in order to prepare for the final check.
522+
* .flatMap(decodeResponseBytes)
523+
* // Make sure that the response has the expected content.
524+
* .assertEquals("<expected response body>")
525+
* }}}
526+
*
527+
* @param pf
528+
* a partial function that matches the value obtained from the effect
529+
*/
530+
def mapOrFail[B](
531+
pf: PartialFunction[A, B],
532+
clue: => Any = "value didn't match any of the defined cases"
533+
)(implicit loc: Location): SyncIO[B] =
534+
mapOrFailF(io, pf, clue)
535+
436536
/** Intercepts a `Throwable` being thrown inside this effect.
437537
*
438538
* @example

core/src/test/scala/munit/CatsEffectAssertionsSyntaxSpec.scala

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,29 @@ class CatsEffectAssertionsSyntaxSpec extends CatsEffectSuite {
7373
io.assert
7474
}
7575

76+
test("mapOrFail (for IO) works (successful mapping)") {
77+
val io = IO.sleep(2.millis) *> IO.some(42)
78+
79+
io.mapOrFail { case Some(obtained) if obtained % 2 == 0 => obtained / 2 }
80+
.assertEquals(21)
81+
}
82+
83+
test("mapOrFail (for IO) works (failed mapping)".fail) {
84+
val io = IO.sleep(2.millis) *> IO.some(42)
85+
86+
io.mapOrFail { case Some(obtained) if obtained % 2 == 1 => () }
87+
}
88+
89+
test("mapOrFail (for IO) works (failed mapping with clue)".fail) {
90+
val io = IO.sleep(2.millis) *> IO.some(42)
91+
92+
// This test simply shows what the syntax looks like when a `clue` is provided.
93+
io.mapOrFail(
94+
{ case Some(obtained) if obtained % 2 == 1 => () },
95+
"the clue goes here"
96+
)
97+
}
98+
7699
test("intercept (for IO) works (successful assertion)") {
77100
val io = exception.raiseError[IO, Unit]
78101

@@ -157,6 +180,29 @@ class CatsEffectAssertionsSyntaxSpec extends CatsEffectSuite {
157180
io.assert
158181
}
159182

183+
test("mapOrFail (for SyncIO) works (successful mapping)") {
184+
val io = SyncIO.pure(Some(42))
185+
186+
io.mapOrFail { case Some(obtained) if obtained % 2 == 0 => obtained / 2 }
187+
.assertEquals(21)
188+
}
189+
190+
test("mapOrFail (for SyncIO) works (failed mapping)".fail) {
191+
val io = SyncIO.pure(Some(42))
192+
193+
io.mapOrFail { case Some(obtained) if obtained % 2 == 1 => () }
194+
}
195+
196+
test("mapOrFail (for SyncIO) works (failed mapping with clue)".fail) {
197+
val io = SyncIO.pure(Some(42))
198+
199+
// This test simply shows what the syntax looks like when a `clue` is provided.
200+
io.mapOrFail(
201+
{ case Some(obtained) if obtained % 2 == 1 => () },
202+
"the clue goes here"
203+
)
204+
}
205+
160206
test("intercept (for SyncIO) works (successful assertion)") {
161207
val io = exception.raiseError[SyncIO, Unit]
162208

0 commit comments

Comments
 (0)