1 /++ 2 A module containing the default test discovery logic 3 4 Copyright: © 2017 Szabo Bogdan 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Szabo Bogdan 7 +/ 8 module trial.discovery.unit; 9 10 import std..string; 11 import std.traits; 12 import std.conv; 13 import std.array; 14 import std.file; 15 import std.algorithm; 16 import std.range; 17 import std.typecons; 18 19 import trial.interfaces; 20 import trial.discovery.code; 21 22 static if(__VERSION__ >= 2077) { 23 enum unitTestKey = "__un" ~ "ittest_"; 24 } else { 25 enum unitTestKey = "__un" ~ "ittestL"; 26 } 27 28 enum CommentType 29 { 30 none, 31 begin, 32 end, 33 comment 34 } 35 36 CommentType commentType(T)(T line) 37 { 38 if (line.length < 2) 39 { 40 return CommentType.none; 41 } 42 43 if (line[0 .. 2] == "//") 44 { 45 return CommentType.comment; 46 } 47 48 if (line[0 .. 2] == "/+" || line[0 .. 2] == "/*") 49 { 50 return CommentType.begin; 51 } 52 53 if (line.indexOf("+/") != -1 || line.indexOf("*/") != -1) 54 { 55 return CommentType.end; 56 } 57 58 return CommentType.none; 59 } 60 61 @("It should group comments") 62 unittest 63 { 64 string comments = "//line 1 65 // line 2 66 67 //// other line 68 69 /** line 3 70 line 4 ****/ 71 72 //// other line 73 74 /++ line 5 75 line 6 76 +++/ 77 78 /** line 7 79 * 80 * line 8 81 */"; 82 83 auto results = comments.compressComments; 84 85 results.length.should.equal(6); 86 results[0].value.should.equal("line 1 line 2"); 87 results[1].value.should.equal("other line"); 88 results[2].value.should.equal("line 3 line 4"); 89 results[3].value.should.equal("other line"); 90 results[4].value.should.equal("line 5 line 6"); 91 results[5].value.should.equal("line 7 line 8"); 92 93 results[0].line.should.equal(2); 94 results[1].line.should.equal(4); 95 results[2].line.should.equal(7); 96 results[3].line.should.equal(9); 97 results[4].line.should.equal(13); 98 } 99 100 struct Comment 101 { 102 ulong line; 103 string value; 104 105 string toCode() { 106 return `Comment(` ~ line.to!string ~ `, "` ~ value.replace(`\`, `\\`).replace(`"`, `\"`) ~ `")`; 107 } 108 } 109 110 Comment[] commentGroupToString(T)(T[] group) 111 { 112 if (group.front[1] == CommentType.comment) 113 { 114 auto slice = group.until!(a => a[1] != CommentType.comment).array; 115 116 string value = slice.map!(a => a[2].stripLeft('/').array.to!string).map!(a => a.strip) 117 .join(' ').array.to!string; 118 119 return [Comment(slice[slice.length - 1][0], value)]; 120 } 121 122 if (group.front[1] == CommentType.begin) 123 { 124 auto ch = group.front[2][1]; 125 auto index = 0; 126 127 auto newGroup = group.map!(a => Tuple!(int, CommentType, immutable(char), 128 string)(a[0], a[1], a[2].length > 2 ? a[2][1] : ' ', a[2])).array; 129 130 foreach (item; newGroup) 131 { 132 index++; 133 if (item[1] == CommentType.end && item[2] == ch) 134 { 135 break; 136 } 137 } 138 139 auto slice = group.map!(a => Tuple!(int, CommentType, immutable(char), string)(a[0], 140 a[1], a[2].length > 2 ? a[2][1] : ' ', a[2])).take(index); 141 142 string value = slice.map!(a => a[3].strip).map!(a => a.stripLeft('/') 143 .stripLeft(ch).array.to!string).map!(a => a.strip).join(' ') 144 .until(ch ~ "/").array.stripRight('/').stripRight(ch).strip.to!string; 145 146 return [Comment(slice[slice.length - 1][0], value)]; 147 } 148 149 return []; 150 } 151 152 string getComment(const Comment[] comments, const ulong line, const string defaultValue) pure 153 { 154 auto r = comments.filter!(a => (line - a.line) < 3); 155 156 return r.empty ? defaultValue : r.front.value; 157 } 158 159 bool connects(T)(T a, T b) 160 { 161 auto items = a[0] < b[0] ? [a, b] : [b, a]; 162 163 if (items[1][0] - items[0][0] != 1) 164 { 165 return false; 166 } 167 168 if (a[1] == b[1]) 169 { 170 return true; 171 } 172 173 if (items[0][1] != CommentType.end && items[1][1] != CommentType.begin) 174 { 175 return true; 176 } 177 178 return false; 179 } 180 181 @("check comment types") 182 unittest 183 { 184 "".commentType.should.equal(CommentType.none); 185 "some".commentType.should.equal(CommentType.none); 186 "//some".commentType.should.equal(CommentType.comment); 187 "/+some".commentType.should.equal(CommentType.begin); 188 "/*some".commentType.should.equal(CommentType.begin); 189 "some+/some".commentType.should.equal(CommentType.end); 190 "some*/some".commentType.should.equal(CommentType.end); 191 } 192 193 auto compressComments(string code) 194 { 195 Comment[] result; 196 197 auto lines = code.splitter("\n").map!(a => a.strip).enumerate(1) 198 .map!(a => Tuple!(int, CommentType, string)(a[0], a[1].commentType, a[1])).filter!( 199 a => a[2] != "").array; 200 201 auto tmp = [lines[0]]; 202 auto prev = lines[0]; 203 204 foreach (line; lines[1 .. $]) 205 { 206 if (tmp.length == 0 || line.connects(tmp[tmp.length - 1])) 207 { 208 tmp ~= line; 209 } 210 else 211 { 212 result ~= tmp.commentGroupToString; 213 tmp = [line]; 214 } 215 } 216 217 if (tmp.length > 0) 218 { 219 result ~= tmp.commentGroupToString; 220 } 221 222 return result; 223 } 224 225 /// Remove comment tokens 226 string clearCommentTokens(string text) 227 { 228 return text.strip('/').strip('+').strip('*').strip; 229 } 230 231 /// clearCommentTokens should remove comment tokens 232 unittest 233 { 234 clearCommentTokens("// text").should.equal("text"); 235 clearCommentTokens("///// text").should.equal("text"); 236 clearCommentTokens("/+++ text").should.equal("text"); 237 clearCommentTokens("/*** text").should.equal("text"); 238 clearCommentTokens("/*** text ***/").should.equal("text"); 239 clearCommentTokens("/+++ text +++/").should.equal("text"); 240 } 241 242 size_t extractLine(string name) { 243 static if(__VERSION__ >= 2077) { 244 auto idx = name.indexOf("_d_"); 245 246 if(idx > 0) { 247 idx += 3; 248 auto lastIdx = name.lastIndexOf("_"); 249 250 if(idx != -1 && isNumeric(name[idx .. lastIdx])) { 251 return name[idx .. lastIdx].to!size_t; 252 } 253 } 254 } else { 255 enum len = unitTestKey.length; 256 257 if(name.length < len) { 258 return 0; 259 } 260 261 auto postFix = name[len .. $]; 262 auto idx = postFix.indexOf("_"); 263 264 if(idx != -1 && isNumeric(postFix[0 .. idx])) { 265 return postFix[0 .. idx].to!size_t; 266 } 267 } 268 269 auto pieces = name.split("_") 270 .filter!(a => a != "") 271 .map!(a => a[0] == 'L' ? a[1..$] : a) 272 .filter!(a => a.isNumeric) 273 .map!(a => a.to!size_t).array; 274 275 if(pieces.length > 0) { 276 return pieces[0]; 277 } 278 279 return 0; 280 } 281 282 /// The default test discovery looks for unit test sections and groups them by module 283 class UnitTestDiscovery : ITestDiscovery 284 { 285 TestCase[string][string] testCases; 286 static Comment[][string] comments; 287 288 TestCase[] getTestCases() 289 { 290 return testCases.values.map!(a => a.values).joiner.array; 291 } 292 293 TestCase[] discoverTestCases(string file) 294 { 295 TestCase[] testCases = []; 296 297 version (Have_fluent_asserts) 298 version (Have_libdparse) 299 { 300 import fluentasserts.core.results; 301 302 auto tokens = fileToDTokens(file); 303 304 void noTest() 305 { 306 assert(false, "you can not run this test"); 307 } 308 309 auto iterator = TokenIterator(tokens); 310 auto moduleName = iterator.skipUntilType("module").skipOne.readUntilType(";").strip; 311 312 string lastName; 313 DLangAttribute[] attributes; 314 315 foreach (token; iterator) 316 { 317 auto type = str(token.type); 318 319 if (type == "}") 320 { 321 lastName = ""; 322 attributes = []; 323 } 324 325 if (type == "@") 326 { 327 attributes ~= iterator.readAttribute; 328 } 329 330 if (type == "comment") 331 { 332 if (lastName != "") 333 { 334 lastName ~= " "; 335 } 336 337 lastName ~= token.text.clearCommentTokens; 338 } 339 340 if (type == "version") 341 { 342 iterator.skipUntilType(")"); 343 } 344 345 if (type == "unittest") 346 { 347 auto issues = attributes.filter!(a => a.identifier == "Issue"); 348 auto flakynes = attributes.filter!(a => a.identifier == "Flaky"); 349 auto stringAttributes = attributes.filter!(a => a.identifier == ""); 350 351 Label[] labels = []; 352 353 foreach (issue; issues) 354 { 355 labels ~= Label("issue", issue.value); 356 } 357 358 if (!flakynes.empty) 359 { 360 labels ~= Label("status_details", "flaky"); 361 } 362 363 if (!stringAttributes.empty) 364 { 365 lastName = stringAttributes.front.value.strip; 366 } 367 368 if (lastName == "") 369 { 370 lastName = "unnamed test at line " ~ token.line.to!string; 371 } 372 373 auto testCase = TestCase(moduleName, lastName, &noTest, labels); 374 375 testCase.location = SourceLocation(file, token.line); 376 377 testCases ~= testCase; 378 } 379 } 380 } 381 382 return testCases; 383 } 384 385 void addModule(string file, string moduleName)() 386 { 387 mixin("import " ~ moduleName ~ ";"); 388 mixin("discover!(`" ~ file ~ "`, `" ~ moduleName ~ "`, " ~ moduleName ~ ")(0);"); 389 } 390 391 private 392 { 393 string testName(alias test)(ref Comment[] comments) 394 { 395 string defaultName = test.stringof.to!string; 396 string name = defaultName; 397 398 foreach (attr; __traits(getAttributes, test)) 399 { 400 static if (is(typeof(attr) == string)) 401 { 402 name = attr; 403 } 404 } 405 406 enum len = unitTestKey.length; 407 size_t line; 408 409 try 410 { 411 line = extractLine(name); 412 } catch(Exception) {} 413 414 if (name == defaultName && name.indexOf(unitTestKey) == 0) 415 { 416 try 417 { 418 if(line != 0) { 419 name = comments.getComment(line, defaultName); 420 } 421 } 422 catch (Exception e) 423 { 424 } 425 } 426 427 if (name == defaultName || name == "") { 428 name = "unnamed test at line " ~ line.to!string; 429 } 430 431 return name; 432 } 433 434 SourceLocation testSourceLocation(alias test)(string fileName) 435 { 436 string name = test.stringof.to!string; 437 438 enum len = unitTestKey.length; 439 size_t line; 440 441 try 442 { 443 line = extractLine(name); 444 } 445 catch (Exception e) 446 { 447 return SourceLocation(); 448 } 449 450 return SourceLocation(fileName, line); 451 } 452 453 Label[] testLabels(alias test)() 454 { 455 Label[] labels; 456 457 foreach (attr; __traits(getAttributes, test)) 458 { 459 static if (__traits(hasMember, attr, "labels")) 460 { 461 labels ~= attr.labels; 462 } 463 } 464 465 return labels; 466 } 467 468 void addTestCases(string file, alias moduleName, composite...)() 469 if (composite.length == 1 && isUnitTestContainer!(composite)) 470 { 471 static if( !composite[0].stringof.startsWith("package") && std.traits.moduleName!composite != moduleName ) { 472 return; 473 } else { 474 if(file !in comments) { 475 comments[file] = file.readText.compressComments; 476 } 477 478 foreach (test; __traits(getUnitTests, composite)) 479 { 480 auto testCase = TestCase(moduleName, testName!(test)(comments[file]), { 481 test(); 482 }, testLabels!(test)); 483 484 testCase.location = testSourceLocation!test(file); 485 486 testCases[moduleName][test.mangleof] = testCase; 487 } 488 } 489 } 490 491 void discover(string file, alias moduleName, composite...)(int index) 492 if (composite.length == 1 && isUnitTestContainer!(composite)) 493 { 494 if(index > 10) { 495 return; 496 } 497 498 addTestCases!(file, moduleName, composite); 499 500 static if (isUnitTestContainer!composite) 501 { 502 foreach (member; __traits(allMembers, composite)) 503 { 504 static if(!is( typeof(__traits(getMember, composite, member)) == void)) { 505 static if (__traits(compiles, __traits(getMember, composite, member)) 506 && isSingleField!(__traits(getMember, composite, member)) && isUnitTestContainer!(__traits(getMember, 507 composite, member)) && !isModule!(__traits(getMember, composite, member))) 508 { 509 if (__traits(getMember, composite, member).mangleof !in testCases) 510 { 511 discover!(file, moduleName, __traits(getMember, composite, member))(index + 1); 512 } 513 } 514 } 515 } 516 } 517 } 518 } 519 } 520 521 private template isUnitTestContainer(DECL...) if (DECL.length == 1) 522 { 523 static if (!isAccessible!DECL) 524 { 525 enum isUnitTestContainer = false; 526 } 527 else static if (is(FunctionTypeOf!(DECL[0]))) 528 { 529 enum isUnitTestContainer = false; 530 } 531 else static if (is(DECL[0]) && !isAggregateType!(DECL[0])) 532 { 533 enum isUnitTestContainer = false; 534 } 535 else static if (isPackage!(DECL[0])) 536 { 537 enum isUnitTestContainer = true; 538 } 539 else static if (isModule!(DECL[0])) 540 { 541 enum isUnitTestContainer = DECL[0].stringof != "module object"; 542 } 543 else static if (!__traits(compiles, fullyQualifiedName!(DECL[0]))) 544 { 545 enum isUnitTestContainer = false; 546 } 547 else static if (!is(typeof(__traits(allMembers, DECL[0])))) 548 { 549 enum isUnitTestContainer = false; 550 } 551 else 552 { 553 enum isUnitTestContainer = true; 554 } 555 } 556 557 private template isModule(DECL...) if (DECL.length == 1) 558 { 559 static if (is(DECL[0])) 560 enum isModule = false; 561 else static if (is(typeof(DECL[0])) && !is(typeof(DECL[0]) == void)) 562 enum isModule = false; 563 else static if (!is(typeof(DECL[0].stringof))) 564 enum isModule = false; 565 else static if (is(FunctionTypeOf!(DECL[0]))) 566 enum isModule = false; 567 else 568 enum isModule = DECL[0].stringof.startsWith("module "); 569 } 570 571 private template isPackage(DECL...) if (DECL.length == 1) 572 { 573 static if (is(DECL[0])) 574 enum isPackage = false; 575 else static if (is(typeof(DECL[0])) && !is(typeof(DECL[0]) == void)) 576 enum isPackage = false; 577 else static if (!is(typeof(DECL[0].stringof))) 578 enum isPackage = false; 579 else static if (is(FunctionTypeOf!(DECL[0]))) 580 enum isPackage = false; 581 else 582 enum isPackage = DECL[0].stringof.startsWith("package "); 583 } 584 585 private template isAccessible(DECL...) if (DECL.length == 1) 586 { 587 enum isAccessible = __traits(compiles, testTempl!(DECL[0])()); 588 } 589 590 private template isSingleField(DECL...) 591 { 592 enum isSingleField = DECL.length == 1; 593 } 594 595 private void testTempl(X...)() if (X.length == 1) 596 { 597 static if (is(X[0])) 598 { 599 auto x = X[0].init; 600 } 601 else 602 { 603 auto x = X[0].stringof; 604 } 605 } 606 607 /// This adds asserts to the module 608 version (unittest) 609 { 610 version(Have_fluent_asserts) { 611 import fluent.asserts; 612 } 613 } 614 615 /// It should extract the line from the default test name 616 unittest { 617 extractLine("__unittest_runTestsOnDevices_133_0()").should.equal(133); 618 } 619 620 /// It should extract the line from the default test name with _d_ in symbol name 621 unittest { 622 extractLine("__unittest_runTestsOnDevices_d_133_0()").should.equal(133); 623 } 624 625 @("It should extract the line from the default test name with _L in symbol name") 626 unittest { 627 extractLine("__unittest_L607_C1()").should.equal(607); 628 } 629 630 /// It should find this test 631 unittest 632 { 633 auto testDiscovery = new UnitTestDiscovery; 634 635 testDiscovery.addModule!(__FILE__, "trial.discovery.unit"); 636 637 testDiscovery.testCases.keys.should.contain("trial.discovery.unit"); 638 639 testDiscovery.testCases["trial.discovery.unit"].values.map!"a.name".should.contain( 640 "It should find this test"); 641 } 642 643 /// It should find this flaky test 644 @Flaky unittest 645 { 646 auto testDiscovery = new UnitTestDiscovery; 647 648 testDiscovery.addModule!(__FILE__, "trial.discovery.unit"); 649 650 testDiscovery.testCases.keys.should.contain("trial.discovery.unit"); 651 652 auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!( 653 a => a.name == "It should find this flaky test"); 654 655 r.empty.should.equal(false).because("a flaky test is in this module"); 656 r.front.labels.map!(a => a.name).should.equal(["status_details"]); 657 r.front.labels[0].value.should.equal("flaky"); 658 } 659 660 /// It should find the line of this test 661 unittest 662 { 663 enum line = __LINE__ - 2; 664 auto testDiscovery = new UnitTestDiscovery; 665 666 testDiscovery.addModule!(__FILE__, "trial.discovery.unit"); 667 668 testDiscovery.testCases.keys.should.contain("trial.discovery.unit"); 669 670 auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!( 671 a => a.name == "It should find the line of this test"); 672 673 r.empty.should.equal(false).because("the location should be present"); 674 r.front.location.fileName.should.endWith("unit.d"); 675 r.front.location.line.should.equal(line); 676 } 677 678 /// It should find this test with issues attributes 679 @Issue("1") @Issue("2") 680 unittest 681 { 682 auto testDiscovery = new UnitTestDiscovery; 683 684 testDiscovery.addModule!(__FILE__, "trial.discovery.unit"); 685 testDiscovery.testCases.keys.should.contain("trial.discovery.unit"); 686 687 auto r = testDiscovery.testCases["trial.discovery.unit"].values.filter!( 688 a => a.name == "It should find this test with issues attributes"); 689 690 r.empty.should.equal(false).because("an issue test is in this module"); 691 r.front.labels.map!(a => a.name).should.equal(["issue", "issue"]); 692 r.front.labels.map!(a => a.value).should.equal(["1", "2"]); 693 } 694 695 /// The discoverTestCases should find the test with issues attributes 696 unittest 697 { 698 immutable line = __LINE__ - 2; 699 auto testDiscovery = new UnitTestDiscovery; 700 701 auto tests = testDiscovery.discoverTestCases(__FILE__); 702 tests.length.should.be.greaterThan(0); 703 704 auto testFilter = tests.filter!(a => a.name == "It should find this test with issues attributes"); 705 testFilter.empty.should.equal(false); 706 707 auto theTest = testFilter.front; 708 709 theTest.labels.map!(a => a.name).should.equal(["issue", "issue"]); 710 theTest.labels.map!(a => a.value).should.equal(["1", "2"]); 711 } 712 713 /// The discoverTestCases should find the test with the flaky attribute 714 unittest 715 { 716 immutable line = __LINE__ - 2; 717 auto testDiscovery = new UnitTestDiscovery; 718 719 auto tests = testDiscovery.discoverTestCases(__FILE__); 720 tests.length.should.be.greaterThan(0); 721 722 auto testFilter = tests.filter!(a => a.name == "It should find this flaky test"); 723 testFilter.empty.should.equal(false); 724 725 auto theTest = testFilter.front; 726 727 theTest.labels.map!(a => a.name).should.equal(["status_details"]); 728 theTest.labels.map!(a => a.value).should.equal(["flaky"]); 729 } 730 731 @("", "The discoverTestCases should find the test with the string attribute name") 732 unittest 733 { 734 immutable line = __LINE__ - 2; 735 auto testDiscovery = new UnitTestDiscovery; 736 737 auto tests = testDiscovery.discoverTestCases(__FILE__); 738 tests.length.should.be.greaterThan(0); 739 740 auto testFilter = tests.filter!( 741 a => a.name == "The discoverTestCases should find the test with the string attribute name"); 742 testFilter.empty.should.equal(false); 743 744 testFilter.front.labels.length.should.equal(0); 745 } 746 747 /// The discoverTestCases 748 /// should find this test 749 unittest 750 { 751 immutable line = __LINE__ - 2; 752 auto testDiscovery = new UnitTestDiscovery; 753 754 auto tests = testDiscovery.discoverTestCases(__FILE__); 755 756 tests.length.should.be.greaterThan(0); 757 758 auto testFilter = tests.filter!(a => a.name == "The discoverTestCases should find this test"); 759 testFilter.empty.should.equal(false); 760 761 auto thisTest = testFilter.front; 762 763 thisTest.suiteName.should.equal("trial.discovery.unit"); 764 thisTest.location.fileName.should.equal(__FILE__); 765 thisTest.location.line.should.equal(line); 766 } 767 768 /// discoverTestCases should ignore version(unittest) 769 unittest 770 { 771 auto testDiscovery = new UnitTestDiscovery; 772 773 auto tests = testDiscovery.discoverTestCases(__FILE__); 774 tests.length.should.be.greaterThan(0); 775 776 auto testFilter = tests.filter!(a => a.name == "This adds asserts to the module"); 777 testFilter.empty.should.equal(true); 778 } 779 780 unittest 781 { 782 /// discoverTestCases should set the default test names 783 immutable line = __LINE__ - 3; 784 auto testDiscovery = new UnitTestDiscovery; 785 786 testDiscovery.discoverTestCases(__FILE__).map!(a => a.name) 787 .array.should.contain("unnamed test at line 780"); 788 } 789 790 /// discoverTestCases should find the same tests like testCases 791 unittest 792 { 793 auto testDiscovery = new UnitTestDiscovery; 794 795 testDiscovery.addModule!(__FILE__, "trial.discovery.unit"); 796 797 auto allTests = testDiscovery 798 .getTestCases 799 .sort!((a, b) => a.location.line < b.location.line) 800 .array; 801 802 testDiscovery 803 .discoverTestCases(__FILE__).map!(a => a.toString).join("\n") 804 .should.equal(allTests.map!(a => a.toString).join("\n")); 805 }