Repository: Textualize/textual

Issue:
Exception not being raised in tests
Consider such an example:

```python
from __future__ import annotations

from textual import work
from textual.app import App


class SimpleAppThatCrashes(App[None]):
    def on_mount(self) -> None:
        self.crash()

    @work(name="crash")
    async def crash(self) -> None:
        raise ValueError("I'm crashing!")


async def test_exception_not_being_raised():
    async with SimpleAppThatCrashes().run_test() as pilot:
        await pilot.pause(1)

# SimpleAppThatCrashes().run()   # Uncomment and run as regular app
```

when running via pilot in pytest - everything is green.

But when running this app as regular :

```python
╭─────────────────────────────────────────────────────────────────────────────────────────── Traceback (most recent call last) ───────────────────────────────────────────────────────────────────────────────────────────╮
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:365 in _run                                                                                                                          │
│                                                                                                                                                                                                                         │
│   362 │   │   self.state = WorkerState.RUNNING                                                 ╭──────────────────────────────────────── locals ────────────────────────────────────────╮                               │
│   363 │   │   app.log.worker(self)                                                             │       app = SimpleAppThatCrashes(title='SimpleAppThatCrashes', classes={'-dark-mode'}) │                               │
│   364 │   │   try:                                                                             │     error = ValueError("I'm crashing!")                                                │                               │
│ ❱ 365 │   │   │   self._result = await self.run()                                              │      self = <Worker ERROR name='crash' description='crash()'>                          │                               │
│   366 │   │   except asyncio.CancelledError as error:                                          │ Traceback = <class 'rich.traceback.Traceback'>                                         │                               │
│   367 │   │   │   self.state = WorkerState.CANCELLED                                           ╰────────────────────────────────────────────────────────────────────────────────────────╯                               │
│   368 │   │   │   self._error = error                                                                                                                                                                                   │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:349 in run                                                                                                                           │
│                                                                                                                                                                                                                         │
│   346 │   │   Returns:                                                                         ╭───────────────────────── locals ─────────────────────────╮                                                             │
│   347 │   │   │   Return value of the work.                                                    │ self = <Worker ERROR name='crash' description='crash()'> │                                                             │
│   348 │   │   """                                                                              ╰──────────────────────────────────────────────────────────╯                                                             │
│ ❱ 349 │   │   return await (                                                                                                                                                                                            │
│   350 │   │   │   self._run_threaded() if self._thread_worker else self._run_async()                                                                                                                                    │
│   351 │   │   )                                                                                                                                                                                                         │
│   352                                                                                                                                                                                                                   │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/.pyenv/versions/textual/lib/python3.10/site-packages/textual/worker.py:334 in _run_async                                                                                                                    │
│                                                                                                                                                                                                                         │
│   331 │   │   │   or hasattr(self._work, "func")                                               ╭───────────────────────── locals ─────────────────────────╮                                                             │
│   332 │   │   │   and inspect.iscoroutinefunction(self._work.func)                             │ self = <Worker ERROR name='crash' description='crash()'> │                                                             │
│   333 │   │   ):                                                                               ╰──────────────────────────────────────────────────────────╯                                                             │
│ ❱ 334 │   │   │   return await self._work()                                                                                                                                                                             │
│   335 │   │   elif inspect.isawaitable(self._work):                                                                                                                                                                     │
│   336 │   │   │   return await self._work                                                                                                                                                                               │
│   337 │   │   elif callable(self._work):                                                                                                                                                                                │
│                                                                                                                                                                                                                         │
│ /home/mzebrak/1workspace/textual/textual_pytest_exceptions.py:13 in crash                                                                                                                                                 │
│                                                                                                                                                                                                                         │
│   10 │                                                                                        ╭───────────────────────────────────── locals ──────────────────────────────────────╮                                     │
│   11 │   @work(name="crash")                                                                  │ self = SimpleAppThatCrashes(title='SimpleAppThatCrashes', classes={'-dark-mode'}) │                                     │
│   12 │   async def crash(self) -> None:                                                       ╰───────────────────────────────────────────────────────────────────────────────────╯                                     │
│ ❱ 13 │   │   raise ValueError("I'm crashing!")                                                                                                                                                                          │
│   14                                                                                                                                                                                                                    │
│   15                                                                                                                                                                                                                    │
│   16 async def test_exception_not_being_raised():                                                                                                                                                                       │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
ValueError: I'm crashing!
```



Discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Another intresting (even simplier) case:

```python
from __future__ import annotations

from textual.app import App, ComposeResult
from textual.screen import Screen


class SomeScreen(Screen):
    def compose(self) -> ComposeResult:
        self.query_one("#not-existing")
        return []


class SimpleAppThatCrashes(App[None]):
    def on_mount(self) -> None:
        self.push_screen(SomeScreen())


async def test_exception_not_being_raised():
    async with SimpleAppThatCrashes().run_test() as pilot:
        await pilot.pause(1)
        assert isinstance(pilot.app.screen, SomeScreen)


# SimpleAppThatCrashes().run()  # Uncomment and run as regular app
```
I think we can make `run_test()` re-raise fatal errors, such as those raised from workers.

Resolving patch:
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3618,10 +3618,8 @@ async def _compose(self) -> None:
             raise TypeError(
                 f"{self!r} compose() method returned an invalid result; {error}"
             ) from error
-        except Exception:
-            from rich.traceback import Traceback
-
-            self.app.panic(Traceback())
+        except Exception as error:
+            self.app._handle_exception(error)
         else:
             self._extend_compose(widgets)
             await self.mount_composed_widgets(widgets)
diff --git a/src/textual/worker.py b/src/textual/worker.py
--- a/src/textual/worker.py
+++ b/src/textual/worker.py
@@ -375,7 +375,8 @@ async def _run(self, app: App) -> None:
 
             app.log.worker(Traceback())
             if self.exit_on_error:
-                app._fatal_error()
+                worker_failed = WorkerFailed(self._error)
+                app._handle_exception(worker_failed)
         else:
             self.state = WorkerState.SUCCESS
             app.log.worker(self)


Test patch:
diff --git a/tests/test_pilot.py b/tests/test_pilot.py
--- a/tests/test_pilot.py
+++ b/tests/test_pilot.py
@@ -2,12 +2,14 @@
 
 import pytest
 
-from textual import events
+from textual import events, work
 from textual.app import App, ComposeResult
 from textual.binding import Binding
 from textual.containers import Center, Middle
 from textual.pilot import OutOfBounds
+from textual.screen import Screen
 from textual.widgets import Button, Label
+from textual.worker import WorkerFailed
 
 KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation
 """Test some "simple" characters (letters + digits) and all punctuation."""
@@ -56,7 +58,7 @@ def on_key(self, event: events.Key) -> None:
             assert keys_pressed[-1] == char
 
 
-async def test_pilot_exception_catching_compose():
+async def test_pilot_exception_catching_app_compose():
     """Ensuring that test frameworks are aware of exceptions
     inside compose methods when running via Pilot run_test()."""
 
@@ -70,6 +72,21 @@ def compose(self) -> ComposeResult:
             pass
 
 
+async def test_pilot_exception_catching_widget_compose():
+    class SomeScreen(Screen[None]):
+        def compose(self) -> ComposeResult:
+            1 / 0
+            yield Label("Beep")
+
+    class FailingApp(App[None]):
+        def on_mount(self) -> None:
+            self.push_screen(SomeScreen())
+
+    with pytest.raises(ZeroDivisionError):
+        async with FailingApp().run_test():
+            pass
+
+
 async def test_pilot_exception_catching_action():
     """Ensure that exceptions inside action handlers are presented
     to the test framework when running via Pilot run_test()."""
@@ -85,6 +102,21 @@ def action_beep(self) -> None:
             await pilot.press("b")
 
 
+async def test_pilot_exception_catching_worker():
+    class SimpleAppThatCrashes(App[None]):
+        def on_mount(self) -> None:
+            self.crash()
+
+        @work(name="crash")
+        async def crash(self) -> None:
+            1 / 0
+
+    with pytest.raises(WorkerFailed) as exc:
+        async with SimpleAppThatCrashes().run_test():
+            pass
+        assert exc.type is ZeroDivisionError
+
+
 async def test_pilot_click_screen():
     """Regression test for https://github.com/Textualize/textual/issues/3395.
 
diff --git a/tests/workers/test_worker.py b/tests/workers/test_worker.py
--- a/tests/workers/test_worker.py
+++ b/tests/workers/test_worker.py
@@ -113,10 +113,10 @@ class ErrorApp(App):
         pass
 
     app = ErrorApp()
-    async with app.run_test():
-        worker: Worker[str] = Worker(app, run_error)
-        worker._start(app)
-        with pytest.raises(WorkerFailed):
+    with pytest.raises(WorkerFailed):
+        async with app.run_test():
+            worker: Worker[str] = Worker(app, run_error)
+            worker._start(app)
             await worker.wait()
 
 
@@ -218,12 +218,12 @@ async def self_referential_work():
         await get_current_worker().wait()
 
     app = App()
-    async with app.run_test():
-        worker = Worker(app, self_referential_work)
-        worker._start(app)
-        with pytest.raises(WorkerFailed) as exc:
+    with pytest.raises(WorkerFailed) as exc:
+        async with app.run_test():
+            worker = Worker(app, self_referential_work)
+            worker._start(app)
             await worker.wait()
-            assert exc.type is DeadlockError
+        assert exc.type is DeadlockError
 
 
 async def test_wait_without_start():