EnforceAaaPatternRector::refactor()   C
last analyzed

Complexity

Conditions 12
Paths 24

Size

Total Lines 50
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 12.3192

Importance

Changes 6
Bugs 2 Features 2
Metric Value
cc 12
eloc 23
c 6
b 2
f 2
nc 24
nop 1
dl 0
loc 50
ccs 20
cts 23
cp 0.8696
crap 12.3192
rs 6.9666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SavinMikhail\EnforceAaaPatternRector;
6
7
use PhpParser\Comment;
8
use PhpParser\Node;
9
use PhpParser\Node\Expr\MethodCall;
10
use PhpParser\Node\Expr\StaticCall;
11
use PhpParser\Node\Stmt;
12
use PhpParser\Node\Stmt\ClassMethod;
13
use PhpParser\Node\Stmt\Expression;
14
use Rector\Rector\AbstractRector;
15
use Symplify\RuleDocGenerator\Exception\PoorDocumentationException;
16
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
17
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
18
19
use function in_array;
20
21
final class EnforceAaaPatternRector extends AbstractRector
22
{
23
    /**
24
     * @throws PoorDocumentationException
25
     */
26
    public function getRuleDefinition(): RuleDefinition
27
    {
28
        return new RuleDefinition(
29
            description: 'Enforce AAA (Arrange-Act-Assert) pattern in PHPUnit test methods',
30
            codeSamples: [
31
                new CodeSample(
32
                    badCode: <<<'PHP_WRAP'
33
                        final class FooTest extends PHPUnit\Framework\TestCase
34
                        {
35
                            public function testFoo(): void
36
                            {
37
                                $date = new DateTimeImmutable('2025-01-01');
38
                                $formatted = $date->format('Y-m-d');
39
                                $this->assertEquals('2025-01-01', $formatted);
40
                            }
41
                        }
42
                        PHP_WRAP,
43
                    goodCode: <<<'PHP_WRAP'
44
                        final class FooTest extends PHPUnit\Framework\TestCase
45
                        {
46
                            public function testFoo(): void
47
                            {
48
                                // Arrange
49
                                $date = new DateTimeImmutable('2025-01-01');
50
                                // Act
51
                                $formatted = $date->format('Y-m-d');
52
                                // Assert
53
                                $this->assertEquals('2025-01-01', $formatted);
54
                            }
55
                        }
56
                        PHP_WRAP,
57
                ),
58
            ],
59
        );
60
    }
61
62 7
    public function getNodeTypes(): array
63
    {
64 7
        return [ClassMethod::class];
65
    }
66
67 7
    private function isTestMethod(ClassMethod $classMethod): bool
68
    {
69 7
        $name = $this->getName(node: $classMethod);
70 7
        if (str_starts_with(haystack: $name, needle: 'test')) {
0 ignored issues
show
Bug introduced by
It seems like $name can also be of type null; however, parameter $haystack of str_starts_with() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

70
        if (str_starts_with(/** @scrutinizer ignore-type */ haystack: $name, needle: 'test')) {
Loading history...
71 6
            return true;
72
        }
73
74 1
        $docComment = $classMethod->getDocComment();
75
76 1
        return $docComment !== null && str_contains(haystack: strtolower(string: $docComment->getText()), needle: '@test');
77
    }
78
79 3
    private function isAaaComment(Comment $comment): bool
80
    {
81 3
        $text = trim(string: $comment->getText());
82 3
        $coreText = trim(string: (string) preg_replace(pattern: '/^\/\/\s*|^\/\*\s*|\s*\*\/$/', replacement: '', subject: $text));
83 3
        $coreTextLower = strtolower(string: $coreText);
84
85 3
        return in_array(needle: $coreTextLower, haystack: ['arrange', 'act', 'assert'], strict: true);
86
    }
87
88 6
    private function removeAaaComments(Node $stmt): void
89
    {
90 6
        $filteredComments = [];
91 6
        foreach ($stmt->getComments() as $comment) {
92 3
            if (! $this->isAaaComment(comment: $comment)) {
93 2
                $filteredComments[] = $comment;
94
            }
95
        }
96
97 6
        $stmt->setAttribute('comments', $filteredComments);
98
    }
99
100 6
    private function addAaaComment(Node $stmt, string $aaaComment): void
101
    {
102 6
        $existing = $stmt->getComments();
103 6
        $aaa = new Comment(text: '// ' . $aaaComment);
104 6
        $stmt->setAttribute('comments', array_merge([$aaa], $existing));
105
    }
106
107 6
    private function isAssertCall(Node\Expr $expr): bool
108
    {
109 6
        if ($expr instanceof MethodCall
110 6
            && $expr->var instanceof Node\Expr\Variable
111 6
            && $this->isName(node: $expr->var, name: 'this')
112
        ) {
113 5
            $methodName = $this->getName(node: $expr->name);
114
115 5
            return $methodName !== null && str_starts_with(haystack: $methodName, needle: 'assert');
116
        }
117
118 6
        if ($expr instanceof StaticCall
119 6
            && $expr->class instanceof Node\Name
120 6
            && $this->isName(node: $expr->class, name: 'self')
121
        ) {
122 1
            $methodName = $this->getName(node: $expr->name);
123
124 1
            return $methodName !== null && str_starts_with(haystack: $methodName, needle: 'assert');
125
        }
126
127 6
        return false;
128
    }
129
130
    /**
131
     * @param Stmt[] $stmts
132
     */
133 6
    private function findFirstAssert(array $stmts): ?int
134
    {
135 6
        foreach ($stmts as $i => $stmt) {
136 6
            if ($stmt instanceof Expression && $this->isAssertCall(expr: $stmt->expr)) {
137 6
                return $i;
138
            }
139
        }
140
141
        return null;
142
    }
143
144
    /**
145
     * @param Stmt[] $stmts
146
     */
147 5
    private function findLastNonAssert(array $stmts, int $firstAssertIndex): int
148
    {
149
        // Find the last statement before the first assert that is not an assert
150 5
        for ($i = $firstAssertIndex - 1; $i >= 0; --$i) {
151 5
            if (! $this->isAssertStatement(stmt: $stmts[$i])) {
152 5
                return $i;
153
            }
154
        }
155
156
        return $firstAssertIndex - 1; // fallback
157
    }
158
159 5
    private function isAssertStatement(Stmt $stmt): bool
160
    {
161 5
        return $stmt instanceof Expression && $this->isAssertCall(expr: $stmt->expr);
162
    }
163
164 7
    public function refactor(Node $node): ?Node
165
    {
166 7
        if (! $node instanceof ClassMethod) {
167
            return null;
168
        }
169
170 7
        if (! $this->isTestMethod(classMethod: $node)) {
171 1
            return null;
172
        }
173
174 6
        if ($node->stmts === null || $node->stmts === []) {
175
            return null;
176
        }
177
178 6
        $stmts = $node->stmts;
179
180 6
        $firstAssertIndex = $this->findFirstAssert(stmts: $stmts);
181
182 6
        if ($firstAssertIndex === null) {
183
            return null;
184
        }
185
186
        // Remove all existing AAA comments first
187 6
        foreach ($stmts as $stmt) {
188 6
            $this->removeAaaComments(stmt: $stmt);
189
        }
190
191
        // Handle simple case: only one statement before assert (treat as Act only)
192 6
        if ($firstAssertIndex === 1) {
193 1
            $this->addAaaComment(stmt: $stmts[0], aaaComment: 'Act');
194
        } else {
195
            // Multiple statements before assert
196
            // First statement gets "Arrange"
197 5
            if (isset($stmts[0])) {
198 5
                $this->addAaaComment(stmt: $stmts[0], aaaComment: 'Arrange');
199
            }
200
201
            // Last non-assert statement before first assert gets "Act"
202 5
            $lastActIndex = $this->findLastNonAssert(stmts: $stmts, firstAssertIndex: $firstAssertIndex);
203 5
            if ($lastActIndex > 0 && isset($stmts[$lastActIndex])) {
204 5
                $this->addAaaComment(stmt: $stmts[$lastActIndex], aaaComment: 'Act');
205
            }
206
        }
207
208
        // First assert gets "Assert"
209 6
        if (isset($stmts[$firstAssertIndex])) {
210 6
            $this->addAaaComment(stmt: $stmts[$firstAssertIndex], aaaComment: 'Assert');
211
        }
212
213 6
        return $node;
214
    }
215
}
216