Skip to content

Commit be64094

Browse files
committed
Restore default multilang structure;
Add article `Testo. Assert и Expect`
1 parent 38247f0 commit be64094

23 files changed

Lines changed: 477 additions & 170 deletions

.vitepress/config.mts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ export default defineConfig({
77
lastUpdated: true,
88
cleanUrls: true,
99

10-
rewrites: {
11-
'pages/en/index.md': 'index.md',
12-
'pages/ru/index.md': 'ru/index.md',
13-
},
14-
1510
head: [
1611
['link', { rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }],
1712
],
@@ -22,15 +17,15 @@ export default defineConfig({
2217
lang: 'en',
2318
themeConfig: {
2419
nav: [
25-
{ text: 'Docs', link: '/docs/en/getting-started' },
26-
{ text: 'Blog', link: '/blog/en/' },
20+
{ text: 'Docs', link: '/docs/getting-started' },
21+
{ text: 'Blog', link: '/blog/' },
2722
],
2823
sidebar: {
29-
'/docs/en/': [
24+
'/docs/': [
3025
{
3126
text: 'Introduction',
3227
items: [
33-
{ text: 'Getting Started', link: '/docs/en/getting-started' },
28+
{ text: 'Getting Started', link: '/docs/getting-started' },
3429
],
3530
},
3631
],
@@ -43,15 +38,15 @@ export default defineConfig({
4338
link: '/ru/',
4439
themeConfig: {
4540
nav: [
46-
{ text: 'Документация', link: '/docs/ru/getting-started' },
47-
{ text: 'Блог', link: '/blog/ru/' },
41+
{ text: 'Документация', link: '/ru/docs/getting-started' },
42+
{ text: 'Блог', link: '/ru/blog/' },
4843
],
4944
sidebar: {
50-
'/docs/ru/': [
45+
'/ru/docs/': [
5146
{
5247
text: 'Введение',
5348
items: [
54-
{ text: 'Начало работы', link: '/docs/ru/getting-started' },
49+
{ text: 'Начало работы', link: '/ru/docs/getting-started' },
5550
],
5651
},
5752
],

.vitepress/theme/BlogSponsor.vue

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<script setup>
2+
import { useRoute } from 'vitepress'
3+
4+
const route = useRoute()
5+
const isBlog = () => route.path.includes('/blog/')
6+
</script>
7+
8+
<template>
9+
<div v-if="isBlog()" class="blog-sponsor">
10+
<div class="sponsor-box">
11+
<span class="sponsor-icon">❤️</span>
12+
<div class="sponsor-content">
13+
<p>
14+
<template v-if="route.path.startsWith('/ru/')">
15+
Вы можете стать спонсором <a href="https://github.com/php-testo" target="_blank">Testo</a>
16+
или поддержать автора на <a href="https://boosty.to/roxblnfk" target="_blank">boosty.to/roxblnfk</a>
17+
</template>
18+
<template v-else>
19+
You can become a sponsor of <a href="https://github.com/php-testo" target="_blank">Testo</a>
20+
or support the author at <a href="https://boosty.to/roxblnfk" target="_blank">boosty.to/roxblnfk</a>
21+
</template>
22+
</p>
23+
</div>
24+
</div>
25+
</div>
26+
</template>
27+
28+
<style scoped>
29+
.blog-sponsor {
30+
margin-top: 2rem;
31+
padding-top: 1.5rem;
32+
border-top: 1px solid var(--vp-c-divider);
33+
}
34+
35+
.sponsor-box {
36+
display: flex;
37+
align-items: flex-start;
38+
gap: 0.75rem;
39+
padding: 1rem;
40+
background: var(--vp-c-bg-soft);
41+
border-radius: 8px;
42+
}
43+
44+
.sponsor-icon {
45+
font-size: 1.25rem;
46+
line-height: 1.5;
47+
}
48+
49+
.sponsor-content p {
50+
margin: 0;
51+
line-height: 1.6;
52+
color: var(--vp-c-text-1);
53+
}
54+
55+
.sponsor-content a {
56+
color: var(--vp-c-brand-1);
57+
text-decoration: none;
58+
}
59+
60+
.sponsor-content a:hover {
61+
text-decoration: underline;
62+
}
63+
</style>

.vitepress/theme/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { h } from 'vue'
2+
import type { Theme } from 'vitepress'
3+
import DefaultTheme from 'vitepress/theme'
4+
import BlogSponsor from './BlogSponsor.vue'
5+
6+
export default {
7+
extends: DefaultTheme,
8+
Layout: () => {
9+
return h(DefaultTheme.Layout, null, {
10+
'doc-after': () => h(BlogSponsor),
11+
})
12+
},
13+
} satisfies Theme

README.md

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,30 @@ Dev server runs at `http://localhost:5173/`
3636
## Project Structure
3737

3838
```
39-
docs/
39+
/
4040
├── .vitepress/
4141
│ └── config.mts # VitePress configuration
42-
├── pages/
43-
│ ├── en/ # Landing page (English) -> /
44-
│ └── ru/ # Landing page (Russian) -> /ru/
45-
├── docs/
46-
│ ├── en/ # Documentation (English)
47-
│ └── ru/ # Documentation (Russian)
48-
├── blog/
49-
│ ├── en/ # Blog articles (English)
50-
│ └── ru/ # Blog articles (Russian)
42+
├── docs/ # Documentation (English)
43+
├── blog/ # Blog articles (English)
44+
├── ru/
45+
│ ├── docs/ # Documentation (Russian)
46+
│ └── blog/ # Blog articles (Russian)
47+
├── index.md # Landing page (English)
48+
├── ru/index.md # Landing page (Russian)
5149
└── public/ # Static assets (logo, images)
5250
```
5351

5452
## Adding Content
5553

5654
### New documentation page
5755

58-
1. Create `docs/en/my-page.md` and `docs/ru/my-page.md`
56+
1. Create `docs/my-page.md` and `ru/docs/my-page.md`
5957
2. Add to sidebar in `.vitepress/config.mts`
6058

6159
### New blog post
6260

63-
1. Create `blog/en/my-post.md` and `blog/ru/my-post.md`
64-
2. Add link to `blog/en/index.md` and `blog/ru/index.md`
61+
1. Create `blog/my-post.md` and `ru/blog/my-post.md`
62+
2. Add link to `blog/index.md` and `ru/blog/index.md`
6563

6664
## Deployment
6765

blog/assert-and-expect.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
# Testo. Assert and Expect
2+
3+
![Testo Assert and Expect](/blog/assert-and-expect/img-1.jpg)
4+
5+
Let's talk about the pitfalls of reinventing the wheel that I've already stumbled upon while building a new testing framework [Testo](https://github.com/php-testo/testo).
6+
7+
PHPUnit provides multiple ways to write the same assertions in tests:
8+
9+
```php
10+
self::assertTrue(...);
11+
$this->assertTrue(...);
12+
assertTrue(...);
13+
```
14+
15+
All these calls lead to one place — the `Assert` facade with ~2300 lines.
16+
17+
::: info 🤔
18+
Did you know that PHPUnit has neither a standalone `expectException()` function nor a method with the same name in the `Assert` facade?
19+
In test code, you can only write `$this->expectException()`.
20+
:::
21+
22+
That's because in PHPUnit tests inherit from `TestCase` (~2400 lines, extends `Assert`), which stores and handles all the test state. Reminds me of Symfony Console architecture, the worst I've encountered so far.
23+
24+
How are test state and `assertException` related? The thing is, `expect` (expectation) differs slightly from `assert` (assertion) in both semantics and mechanics:
25+
26+
- Assertions are checked here and now, "check and forget" style.
27+
- Expectations are checked later (after test completion), i.e., "remember now, check at the end".
28+
29+
**Testo has a different policy.**
30+
31+
From Testo's perspective, the test class belongs to the developer, not the framework. All meta-information and runtime data needed by the framework is stored and processed elsewhere.
32+
33+
That's why test classes don't need to inherit from `TestCase`. In Testo, a test case doesn't run itself and doesn't even know its name in the test environment. This allows for cleaner code, and we can use the constructor however we want.
34+
35+
Tests can even be plain user-defined functions!
36+
37+
```php
38+
#[Test]
39+
function simpleTest(): void
40+
{
41+
// test something
42+
}
43+
```
44+
45+
::: tip 🧠
46+
But now we face a tricky question with lots of room for imagination: **how do we provide a convenient API for assertions?**
47+
:::
48+
49+
Sure, we could eventually create a hundred functions, a trait, and a base class with PHPUnit-like syntax... But hey, let's try to find something better first!
50+
51+
## Shorter and clearer
52+
53+
I decided to start like the [webmozarts/assert](https://github.com/webmozarts/assert) library: since we no longer need to write `self::` or `$this->`, let's keep it simple: `Assert::same()`. I chose the familiar PHPUnit parameter order: **$expected** first, then **$actual** (webmozart puts the value being checked first, then the expected value, which actually **looks more logical**).
54+
55+
Off we went. Made `::same()`, `::notSame`, `::null()`, `::true()`, `::false()`, `::equals()`, `::notEquals()`.
56+
57+
Then we got to `Assert::greaterThan()`. In PHPUnit, the argument order is the same: **$expected** first, then **$actual**.
58+
59+
So if we want to say `$foo is greater than 42`, we have to write `greaterThan(42, $foo)`.
60+
61+
![greaterThan](/blog/assert-and-expect/img-2.png)
62+
63+
Looks disgusting, since everywhere else we use mathematical notation like `$foo > 42`.
64+
65+
After some deliberation, the most understandable, short, and readable option won.
66+
Can you guess which one?
67+
68+
```php
69+
Assert::compare($foo, '>', 42);
70+
71+
Assert::satisfies($foo, '>', 42);
72+
73+
Assert::that($foo)->greaterThan(42);
74+
75+
Assert::true($foo > 42);
76+
```
77+
78+
::: warning ☝
79+
This led us to a strategic decision: provide only "complex" assertions that save characters or entire lines of code.
80+
:::
81+
82+
---
83+
84+
When it came to `expectException()`, the `Assert` facade started feeling uncomfortable.
85+
Initially it looked like `Assert::exception()`. The awkwardness stems from that difference mentioned at the beginning: the semantics (meaning) and mechanics of assertions (checking "here and now").
86+
87+
What are we asserting here? That we assert that we expect an exception to be thrown from the test?
88+
89+
This bugged me for a long time. Lucky Bergmann with his inheritance — no need to think about naming semantics in facades — just shove everything into `$this` and problem solved.
90+
91+
Eventually I came to the conclusion that we need a second facade `Expect`, which would provide post-check assertions.
92+
93+
Pros:
94+
95+
- Developers immediately recognize which check will be performed afterwards.
96+
- No naming dissonance.
97+
98+
Cons:
99+
100+
- No autocomplete from the `Assert` facade, and you need to remember about the second facade. Well, you just have to get used to it.
101+
102+
![Meme](/blog/assert-and-expect/img-3.jpg)
103+
104+
---
105+
106+
## Trying something new
107+
108+
Instead of adding tons of sugar like `::stringContains()`, `::stringEndsWith()` (and 20 more `string*` methods) into one facade, we can group methods by semantics or type:
109+
110+
```php
111+
// Strings
112+
Assert::string($string)->contains("str");
113+
114+
// Files
115+
Assert::file("foo.txt")->notExists();
116+
117+
// Exceptions
118+
Expect::exception(Failure::class)
119+
->fromMethod(Service::class, 'process')
120+
->withMessage("foo bar");
121+
```
122+
123+
This way, at the start of the pipe in `Assert::string()`, we immediately verify that we're actually receiving a string, and in `->contains(...)` we perform the check already confident we're working with the right type.
124+
125+
Code takes less space, facades aren't bloated. Now this is what looks truly elegant. Whether it's usable or not — practice will tell.
126+
127+
![Pipe assertions](/blog/assert-and-expect/img-4.jpg)
128+
129+
---
130+
131+
So we made several of these pipe assertions.
132+
133+
```php
134+
#[Test]
135+
public function checkIterableTraitMethods(): void
136+
{
137+
Assert::instanceOf(\DateTimeInterface::class, new \DateTimeImmutable());
138+
// Shorthand for Assert::object($object)->instanceOf($class);
139+
140+
Assert::int(15)->greaterThan(10);
141+
142+
Assert::array([1,2,3])->allOf('int')->contains(3)->hasKeys(0)->sameSizeAs([4,5,6,7]);
143+
}
144+
```
145+
146+
Can we turn this into useful output?
147+
148+
In Testo, I planned for assertion logging per test from the start. This mechanism had to be reworked a bit for composites after pipe assertions appeared, but that's beside the point.
149+
150+
Let's try displaying the assertion list and see what comes out of it.
151+
152+
**Compact variant.** I like it, but it looks a bit rough and might not appeal to those who dislike abbreviations over language constructs.
153+
154+
![Compact variant](/blog/assert-and-expect/img-5.png)
155+
156+
**Fuller variant.** Here all checks in the pipe are listed separated by semicolons. Reads like a book, and the nested tree element contains the full exception text.
157+
158+
![Fuller variant](/blog/assert-and-expect/img-6.png)
159+
160+
How to improve all this — unclear for now. Maybe go back to compact?
161+
162+
We're also trying out how it would look in an IDE. Will this be useful?
163+
164+
![In IDE](/blog/assert-and-expect/img-7.png)
165+
166+
There's also an option to output each assertion as a nested checkmark in the test tree (like DataSet), but I think that would be too cluttered.
167+
168+
::: tip ☝
169+
It might seem like there are only more open questions. But over time, not only questions appear, but expertise grows too: each answer to a closed question is backed by mental or practical experience.
170+
:::
171+
172+
The final word on assert/expect hasn't been said. But while Testo hasn't reached a stable release, we can afford any experiments.
173+
174+
I won't be surprised if in the future we decide that **$expected** should come after **$actual**, that pipe assertions aren't as convenient and we need functions, and that assertion history output is overkill.
175+
176+
---
177+
178+
[Join](https://t.me/spiralphp/10863) the discussion or development, propose your wildest ideas or features. It's interesting.
179+
180+
Special thanks to:
181+
182+
- [@petrdobr](https://github.com/petrdobr) for help with assertion implementation.
183+
- [@xepozz](https://github.com/xepozz/) for the [IDE plugin](https://plugins.jetbrains.com/plugin/28842-testo), which needs your stars.

blog/assert-and-expect/img-1.jpg

761 KB
Loading

blog/assert-and-expect/img-2.png

1.46 MB
Loading

blog/assert-and-expect/img-3.jpg

265 KB
Loading

blog/assert-and-expect/img-4.jpg

1.09 MB
Loading

blog/assert-and-expect/img-5.png

178 KB
Loading

0 commit comments

Comments
 (0)